Skip to content

Commit 1c53053

Browse files
chore(release): v1.0.3 — Actions dropdown polish + sidebar identity refactor (#579)
Closes #574, Closes #575, Closes #578. Patch — Popover refactor + dropdown polish, no API change.
1 parent e3cdd34 commit 1c53053

9 files changed

Lines changed: 235 additions & 156 deletions

File tree

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

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
EmptyState,
2929
Modal,
3030
Pagination,
31+
Popover,
3132
RecordCardList,
3233
Table,
3334
useMediaQuery,
@@ -536,37 +537,49 @@ export function ListPage() {
536537
// parity — the actions selector leads the toolbar).
537538
leading={
538539
canRunActions && (selected.size > 0 || selectAcross) ? (
539-
<div className="relative">
540-
<button
541-
type="button"
542-
onClick={() => setActionsOpen((o) => !o)}
543-
aria-haspopup="menu"
544-
aria-expanded={actionsOpen}
545-
disabled={runningAction}
546-
className="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
547-
>
548-
Actions · {effectiveCount}
549-
</button>
550-
{actionsOpen && (
551-
<div
552-
role="menu"
553-
className="absolute left-0 z-20 mt-1 min-w-48 rounded border border-gray-200 bg-white py-1 shadow-lg"
540+
// Actions menu (#574 width / #575 outside-click): rendered
541+
// through the shared <Popover> so the menu inherits the
542+
// outside-click + Escape close behaviour from the same
543+
// primitive the sidebar identity dropdown uses (#578). Width
544+
// tracks the longest action label between sensible bounds —
545+
// `min-w-56` (224px) so short labels don't collapse the
546+
// menu, `max-w-md` (28rem / 448px) so a single ridiculously
547+
// long label doesn't blow it open. Items are `whitespace-
548+
// nowrap truncate` so a longer-than-max label gets `…` with
549+
// the full text reachable via the existing `title=` tooltip.
550+
<Popover
551+
open={actionsOpen}
552+
onClose={() => setActionsOpen(false)}
553+
align="left"
554+
panelClassName="min-w-56 max-w-md py-1"
555+
trigger={
556+
<button
557+
type="button"
558+
onClick={() => setActionsOpen((o) => !o)}
559+
aria-haspopup="menu"
560+
aria-expanded={actionsOpen}
561+
disabled={runningAction}
562+
className="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
554563
>
555-
{actions.map((a) => (
556-
<button
557-
key={a.name}
558-
type="button"
559-
role="menuitem"
560-
onClick={() => requestAction(a)}
561-
className="block w-full px-3 py-2 text-left text-sm hover:bg-gray-100"
562-
title={a.description}
563-
>
564-
{a.label}
565-
</button>
566-
))}
567-
</div>
568-
)}
569-
</div>
564+
Actions · {effectiveCount}
565+
</button>
566+
}
567+
>
568+
<div role="menu">
569+
{actions.map((a) => (
570+
<button
571+
key={a.name}
572+
type="button"
573+
role="menuitem"
574+
onClick={() => requestAction(a)}
575+
className="block w-full truncate whitespace-nowrap px-3 py-2 text-left text-sm hover:bg-gray-100"
576+
title={a.description ?? a.label}
577+
>
578+
{a.label}
579+
</button>
580+
))}
581+
</div>
582+
</Popover>
570583
) : null
571584
}
572585
trailing={
Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
# @dar/settings
22

3-
The **Settings dialog** and the user-preference state it owns.
3+
The **identity dropdown panel** (theme + sign out) and the
4+
user-preference state it owns.
45

56
## What lives here
67

7-
- `SettingsModal.tsx` — the dialog opened from the sidebar cog. Built on
8-
the shared `@dar/ui` `Modal` so it matches every other confirm/overlay
9-
in the SPA. Today it holds the appearance (light / dark) toggle; it is
10-
the home for future per-user UI preferences.
8+
- `AccountMenu.tsx` — the dropdown panel rendered inside the sidebar's
9+
email-with-caret button (`#578`). Holds the appearance (light / dark)
10+
toggle and the Sign out action. The component is the **panel body
11+
only**; the consumer wraps it in the shared `@dar/ui` `Popover` so
12+
outside-click + Escape close behaviour is inherited from the same
13+
primitive the list-page Actions menu uses.
1114
- `theme.ts` — the light/dark preference: read/resolve (saved choice →
1215
system default), persist to the `dar:theme` localStorage key, and
1316
apply by toggling the `.dark` class on `<html>`. `initTheme()` is
@@ -17,12 +20,18 @@ The **Settings dialog** and the user-preference state it owns.
1720

1821
- Model-aware logic or anything that talks to the API. This package is
1922
pure UI preference; it imports only `@dar/ui` (+ React / lucide).
20-
- The sidebar chrome that *opens* the modal — that's `@dar/sidebar`.
23+
- The sidebar chrome that *mounts* the panel — that's `@dar/sidebar`.
2124
- The `.dark` utility remap CSS — that stays in the app's `index.css`
2225
(Tailwind layer), since it's global styling, not component logic.
2326

2427
## Pointers
2528

26-
- Rendered by `@dar/sidebar` (the cog button) and bootstrapped by
29+
- Rendered by `@dar/sidebar` (the identity dropdown) and bootstrapped by
2730
`apps/web/src/main.tsx` (`initTheme`).
2831
- Data-flow rule: a UI package never imports `@dar/api` (CLAUDE.md §7).
32+
33+
## History
34+
35+
- v1.0.3: replaced the prior `SettingsModal` with `AccountMenu`. The
36+
modal-around-two-controls was heavier than warranted; the dropdown
37+
is the right primitive for a profile menu.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// AccountMenu (#578) — the panel body for the sidebar's email-with-caret
2+
// dropdown. Replaces the old "Settings" modal: same controls (theme
3+
// toggle + Sign out) but rendered as a dropdown panel instead of a
4+
// modal, since the controls don't warrant a dimmed-overlay modal.
5+
//
6+
// This component is the panel CONTENT only — the consumer wraps it in
7+
// the shared <Popover> so outside-click + Escape close behaviour is
8+
// inherited from the same primitive ListPage's Actions menu uses.
9+
10+
import { useState } from 'react';
11+
import { LogOut, Moon, Sun } from 'lucide-react';
12+
13+
import { resolveTheme, setTheme, type Theme } from '@dar/customization';
14+
import { purgeLocalCache, useApiClient } from '@dar/data';
15+
16+
export interface AccountMenuProps {
17+
/** Hide the panel after a menu action runs (theme stays — only Sign out closes). */
18+
onAfterAction?: (() => void) | undefined;
19+
/** Email / display name shown as a small grayed caption above the controls. Optional. */
20+
identityLabel?: string | undefined;
21+
/** Role caption appended after the email (e.g. "superuser"). Optional. */
22+
roleLabel?: string | undefined;
23+
}
24+
25+
export function AccountMenu({ onAfterAction, identityLabel, roleLabel }: AccountMenuProps) {
26+
const [theme, setThemeState] = useState<Theme>(() => resolveTheme());
27+
const client = useApiClient();
28+
const [loggingOut, setLoggingOut] = useState(false);
29+
30+
const choose = (next: Theme) => {
31+
setTheme(next);
32+
setThemeState(next);
33+
// Theme is a sticky control — keep the menu open so the operator can
34+
// see the change reflect immediately before deciding to close it.
35+
};
36+
37+
const handleLogout = async () => {
38+
setLoggingOut(true);
39+
try {
40+
await client.logout();
41+
} catch {
42+
// Logout is idempotent server-side; even on a transient network
43+
// error we still bounce the user out so the UI can't pretend
44+
// they're still signed in.
45+
}
46+
// Drop every cached server response + per-model UI hint so a logged
47+
// out (or next) user can't read the previous session's data out of
48+
// localStorage. Then a full reload re-runs the auth gate, routing an
49+
// anonymous session to the login screen with clean in-memory state.
50+
purgeLocalCache();
51+
onAfterAction?.();
52+
window.location.reload();
53+
};
54+
55+
const optionClass = (active: boolean): string =>
56+
[
57+
'flex flex-1 items-center justify-center gap-1.5 rounded-md border px-2 py-1.5 text-sm font-medium',
58+
active
59+
? 'border-blue-600 bg-blue-50 text-blue-700'
60+
: 'border-gray-300 text-gray-700 hover:bg-gray-50',
61+
].join(' ');
62+
63+
return (
64+
<div className="w-56 p-3">
65+
{identityLabel && (
66+
<div className="mb-2 truncate text-xs text-gray-500" title={identityLabel}>
67+
Signed in as <span className="font-medium text-gray-700">{identityLabel}</span>
68+
{roleLabel ? <span className="text-gray-400"> · {roleLabel}</span> : null}
69+
</div>
70+
)}
71+
72+
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-gray-500">
73+
Appearance
74+
</div>
75+
<div className="flex gap-2">
76+
<button
77+
type="button"
78+
role="menuitem"
79+
onClick={() => choose('light')}
80+
className={optionClass(theme === 'light')}
81+
aria-pressed={theme === 'light'}
82+
>
83+
<Sun className="h-4 w-4" aria-hidden /> Light
84+
</button>
85+
<button
86+
type="button"
87+
role="menuitem"
88+
onClick={() => choose('dark')}
89+
className={optionClass(theme === 'dark')}
90+
aria-pressed={theme === 'dark'}
91+
>
92+
<Moon className="h-4 w-4" aria-hidden /> Dark
93+
</button>
94+
</div>
95+
96+
<div className="mt-3 border-t border-gray-200 pt-3">
97+
<button
98+
type="button"
99+
role="menuitem"
100+
onClick={handleLogout}
101+
disabled={loggingOut}
102+
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-gray-300 px-2 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
103+
>
104+
<LogOut className="h-4 w-4" aria-hidden />
105+
{loggingOut ? 'Signing out…' : 'Sign out'}
106+
</button>
107+
</div>
108+
</div>
109+
);
110+
}

frontend/packages/settings/src/SettingsModal.tsx

Lines changed: 0 additions & 82 deletions
This file was deleted.

frontend/packages/settings/src/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
// @dar/settings — the Settings dialog + the appearance (theme) state it
2-
// owns. Isolated so the modal can grow its own per-user preference
3-
// surface without touching the app shell or the sidebar.
1+
// @dar/settings — per-user UI preferences (appearance) and session
2+
// controls (Sign out), exposed as a dropdown panel mounted from the
3+
// sidebar identity area (#578).
44

5-
export { SettingsModal } from './SettingsModal';
5+
export { AccountMenu, type AccountMenuProps } from './AccountMenu';
66
// Theme state lives in @dar/customization (the home for all
77
// localStorage-backed UI customization); re-exported here so the app
8-
// shell + Settings modal keep importing it from @dar/settings.
8+
// shell + AccountMenu keep importing it from @dar/settings.
99
export {
1010
applyTheme,
1111
getStoredTheme,

frontend/packages/sidebar/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"dependencies": {
1818
"@dar/customization": "workspace:*",
1919
"@dar/data": "workspace:*",
20-
"@dar/settings": "workspace:*"
20+
"@dar/settings": "workspace:*",
21+
"@dar/ui": "workspace:*"
2122
},
2223
"peerDependencies": {
2324
"lucide-react": "^1.16.0",

0 commit comments

Comments
 (0)