Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 60 additions & 13 deletions src/components/FolderRail.css
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
min-width: 0;
}

/* The note-count badge: quiet, tabular, right-aligned at a steady inset. */
/* The note-count badge: quiet, tabular, flush with the row's edge padding. */
.folder-rail__count {
flex-shrink: 0;
color: var(--g-color-text-hint);
Expand All @@ -115,29 +115,76 @@
font-variant-numeric: tabular-nums;
}

/* The ⋯ menu reveals on hover/focus in a fixed-width trailing slot (the width of the s icon button),
present on every row — incl. the empty spacer on "All Notes" — so the count keeps a single aligned
position across all rows and stays visible when the menu appears (it no longer takes the count's place). */
.folder-rail__actions,
.folder-rail__actions-spacer {
/* The trailing slot holds the count OR the ⋯ menu, sized by whichever is showing. At rest it is
exactly as wide as the count (nothing when there is none), so the name's ellipsis boundary sits
a plain flex-gap from the digits and no gutter is reserved for the menu. On hover/focus the menu
takes the count's place — display, not opacity, so the slot re-sizes to the 24px button and the
name re-flows to make the room. The count rules are scoped to the slot, keeping the All Notes
badge (no menu to reveal) always visible. */
.folder-rail__trailing {
position: relative;
flex-shrink: 0;
width: 24px;
display: flex;
align-items: center;
}

.folder-rail__actions {
display: flex;
justify-content: center;
/* Parked state: the ⋯ sits out of flow (so the slot hugs the count and the name's ellipsis stops
a plain gap from the digits), invisible and click-through — but still rendered and focusable,
so Tab can step from the row into it. Never display: none here: a Tab target that loses its box
while focus is being handed over gets dropped, stranding focus on body. The 5px pull-in is felt
only in the revealed (in-flow) state: the 24px button carries ~7px of air before its glyph, so
without it the name-to-⋯ distance reads wider than the name-to-count it replaces. */
.folder-rail__trailing .folder-rail__actions {
position: absolute;
inset-inline-end: 0;
opacity: 0;
pointer-events: none;
margin-inline-start: -5px;
}

/* Reveal — into the flow — on the selected row, the row's own focus (keyboard nav), while this
row's menu is open, or while the ⋯ itself holds keyboard focus (Tab parity with the note
list's ⋯). The button leg is :focus-visible, NOT plain :focus: closing the menu can strand
pointer-given focus on the button (the panel divider's pointerdown calls preventDefault,
suppressing the focus move that would blur it), and a plain-focus reveal would pin the swap
after the pointer leaves. A stranded button merely stays parked: invisible, inert, and it
yields on the next focus move.

The selected row holding its ⋯ permanently costs it its count — the one folder whose count is
never shown is the one you are in. A debatable trade, challenge welcome; without it the rail
felt inconsistent beside the note list, whose selected item holds its actions the same way.

Reveal is relative, not static: .g-button paints its hover wash, press effect and focus ring on
abspos ::before/::after with inset: 0, so an un-positioned button would anchor those to the
trailing slot (19px wide, shifted by the -5px pull-in). Today uikit's own transform on .g-button
happens to make it a containing block anyway, but that's not worth depending on; offset-less
relative is identical to static in flex flow. */
.folder-rail__row_selected .folder-rail__trailing .folder-rail__actions,
.folder-rail__row:focus .folder-rail__trailing .folder-rail__actions,
.folder-rail__row_menu-open .folder-rail__trailing .folder-rail__actions,
.folder-rail__trailing .folder-rail__actions:focus-visible {
position: relative;
opacity: 1;
pointer-events: auto;
}

.folder-rail__row:focus-within .folder-rail__actions {
opacity: 1;
.folder-rail__row_selected .folder-rail__trailing .folder-rail__count,
.folder-rail__row:focus .folder-rail__trailing .folder-rail__count,
.folder-rail__row_menu-open .folder-rail__trailing .folder-rail__count,
.folder-rail__trailing:has(.folder-rail__actions:focus-visible) .folder-rail__count {
display: none;
}

/* Hover-reveal, pointer-only: on touch this revealed content is what swallowed the tap. */
@media (hover: hover) {
.folder-rail__row:hover .folder-rail__actions {
.folder-rail__row:hover .folder-rail__trailing .folder-rail__actions {
position: relative;
opacity: 1;
pointer-events: auto;
}

.folder-rail__row:hover .folder-rail__trailing .folder-rail__count {
display: none;
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/components/FolderRail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,29 @@ describe('FolderRail — folder actions', () => {
expect(props.onTogglePin).toHaveBeenCalledWith('Work');
});

it('Tab steps from a focused row into its ⋯, then on to New Folder', async () => {
const user = userEvent.setup();
setup({rows: [folder('Work')]});
screen.getByRole('treeitem', {name: /Work/}).focus();
await user.tab();
expect(screen.getByRole('button', {name: 'Work actions'})).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', {name: 'New folder'})).toHaveFocus();
});

it('marks the row while its menu is open, and unmarks it on close', async () => {
const user = userEvent.setup();
setup();
const row = screen.getByRole('treeitem', {name: /Work/});
expect(row).not.toHaveClass('folder-rail__row_menu-open');
await user.click(screen.getByRole('button', {name: 'Work actions'}));
expect(row).toHaveClass('folder-rail__row_menu-open');
// Acting on an item closes the menu — the mark (which keeps the ⋯ revealed while the
// menu is up, wherever the pointer is) must clear with it, not linger on button focus.
await user.click(await screen.findByRole('menuitem', {name: /Pin to top/}));
expect(row).not.toHaveClass('folder-rail__row_menu-open');
});

it('removes an empty folder from its menu', async () => {
const user = userEvent.setup();
const {props} = setup({rows: [folder('Empty', {noteCount: 0, hasChildren: false})]});
Expand Down
23 changes: 14 additions & 9 deletions src/components/FolderRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,11 +423,11 @@ export const FolderRail = forwardRef<FolderRailHandle, FolderRailProps>(function
<Text className="folder-rail__name" ellipsis>
All Notes
</Text>
{/* Last flex item, so the count ends at the row padding — where a folder row's
trailing slot puts its count — keeping the column aligned without a spacer. */}
{allNotesCount > 0 ? (
<span className="folder-rail__count">{allNotesCount}</span>
) : null}
{/* Reserve the same trailing slot as a folder row's ⋯ menu, so counts line up. */}
<span className="folder-rail__actions-spacer" aria-hidden />
</div>
);
};
Expand Down Expand Up @@ -515,7 +515,10 @@ export const FolderRail = forwardRef<FolderRailHandle, FolderRailProps>(function
className={
'folder-rail__row' +
(selected ? ' folder-rail__row_selected' : '') +
(dropTarget === row.path ? ' folder-rail__row_drop-target' : '')
(dropTarget === row.path ? ' folder-rail__row_drop-target' : '') +
// Keeps the ⋯ (and not the count) showing while this row's menu is open,
// however far the pointer wanders (see the reveal rules in FolderRail.css).
(contextMenu?.row.path === row.path ? ' folder-rail__row_menu-open' : '')
}
style={{paddingInlineStart: indentFor(row.depth)}}
role="treeitem"
Expand Down Expand Up @@ -574,14 +577,16 @@ export const FolderRail = forwardRef<FolderRailHandle, FolderRailProps>(function
<Text className="folder-rail__name" ellipsis>
{row.name}
</Text>
{row.noteCount > 0 ? (
<span className="folder-rail__count">{row.noteCount}</span>
) : null}
<div className="folder-rail__actions">
{/* The count and the ⋯ menu share one trailing slot (see FolderRail.css): the
count shows at rest, the menu takes its place on hover/focus. */}
<span className="folder-rail__trailing">
{row.noteCount > 0 ? (
<span className="folder-rail__count">{row.noteCount}</span>
) : null}
<Button
className="folder-rail__actions"
view="flat"
size="s"
tabIndex={-1}
aria-label={`${row.name} actions`}
onClick={(e) => {
// Open the one shared menu anchored to this button — don't also select
Expand All @@ -595,7 +600,7 @@ export const FolderRail = forwardRef<FolderRailHandle, FolderRailProps>(function
>
<Icon data={Ellipsis} />
</Button>
</div>
</span>
</div>
);
};
Expand Down
Loading