Feat: Add workspace context actions
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
.layer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include respond-down(mobile) {
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: block;
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: color-mix(in srgb, black 52%, transparent);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
max-height: calc(100dvh - (var(--space-12) * 2));
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-3) calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-strong);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: var(--space-10);
|
||||
height: var(--space-1);
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 24%, transparent);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
|
||||
}
|
||||
|
||||
.headerCopy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: calc(var(--space-1) / 2);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@include text-caption;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include text-title;
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--control-size-md);
|
||||
height: var(--control-size-md);
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, var(--color-surface-elevated, var(--color-surface)) 16%);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
@include text-caption;
|
||||
padding-inline: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.actionList {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-elevated, var(--color-surface)) 12%);
|
||||
}
|
||||
|
||||
.action {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: calc(var(--control-size-lg) + var(--space-2));
|
||||
padding: 0 var(--space-3);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.action:active {
|
||||
background: color-mix(in srgb, var(--color-text) 6%, transparent);
|
||||
}
|
||||
|
||||
.action + .action {
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
|
||||
}
|
||||
|
||||
.actionLabel {
|
||||
@include text-label;
|
||||
}
|
||||
|
||||
.actionDanger {
|
||||
color: var(--color-danger-text, var(--color-text));
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { For, Show, createMemo, type JSX } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { X } from "../../../lib/icons";
|
||||
import {
|
||||
getWorkspaceContextMenuEyebrow,
|
||||
getWorkspaceContextMenuSections,
|
||||
type WorkspaceContextMenuAction,
|
||||
type WorkspaceContextMenuSection,
|
||||
type WorkspaceContextMenuTarget,
|
||||
} from "../data/shell.data";
|
||||
import styles from "./WorkspaceMobileActionSheet.module.scss";
|
||||
|
||||
type WorkspaceMobileActionSheetProps = {
|
||||
target: WorkspaceContextMenuTarget | null;
|
||||
onClose: VoidFunction;
|
||||
onSelect: (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget) => void;
|
||||
};
|
||||
|
||||
type FlattenedActionSection = {
|
||||
id: string;
|
||||
label?: string;
|
||||
items: readonly WorkspaceContextMenuAction[];
|
||||
};
|
||||
|
||||
const flattenMobileSections = (
|
||||
sections: readonly WorkspaceContextMenuSection[],
|
||||
): readonly FlattenedActionSection[] => {
|
||||
// Mobile uses a flat action-sheet model, so desktop flyout groups become
|
||||
// standalone labeled sections instead of nested menus.
|
||||
return sections.flatMap((section) => {
|
||||
const directActions = section.items.filter((action) => !action.children?.length);
|
||||
const nestedSections = section.items
|
||||
.filter((action) => action.children?.length)
|
||||
.map((action) => ({
|
||||
id: `${section.id}-${action.id}`,
|
||||
label: action.label,
|
||||
items: action.children ?? [],
|
||||
}));
|
||||
|
||||
const flattenedSections: FlattenedActionSection[] = [];
|
||||
|
||||
if (directActions.length > 0) {
|
||||
flattenedSections.push({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
items: directActions,
|
||||
});
|
||||
}
|
||||
|
||||
return [...flattenedSections, ...nestedSections];
|
||||
});
|
||||
};
|
||||
|
||||
export const WorkspaceMobileActionSheet = (props: WorkspaceMobileActionSheetProps): JSX.Element => {
|
||||
const sheetState = createMemo(() => {
|
||||
if (!props.target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
target: props.target,
|
||||
sections: flattenMobileSections(getWorkspaceContextMenuSections(props.target)),
|
||||
};
|
||||
});
|
||||
|
||||
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
|
||||
props.onSelect(action, target);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={sheetState()}>
|
||||
{(sheetState): JSX.Element => {
|
||||
const target = sheetState().target;
|
||||
const sections = sheetState().sections;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div class={styles.layer}>
|
||||
<button class={styles.backdrop} type="button" aria-label="Close action sheet" onClick={props.onClose} />
|
||||
|
||||
<section class={styles.sheet} aria-label={`${target.label} actions`}>
|
||||
<div class={styles.handle} aria-hidden="true" />
|
||||
|
||||
<header class={styles.header}>
|
||||
<div class={styles.headerCopy}>
|
||||
<span class={styles.eyebrow}>{getWorkspaceContextMenuEyebrow(target)}</span>
|
||||
<strong class={styles.title}>{target.label}</strong>
|
||||
</div>
|
||||
|
||||
<button class={styles.closeButton} type="button" aria-label="Close action sheet" onClick={props.onClose}>
|
||||
<X size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class={styles.sectionList}>
|
||||
<For each={sections}>
|
||||
{(section): JSX.Element => (
|
||||
<section class={styles.section}>
|
||||
<Show when={section.label}>
|
||||
<span class={styles.sectionLabel}>{section.label}</span>
|
||||
</Show>
|
||||
|
||||
<div class={styles.actionList}>
|
||||
<For each={section.items}>
|
||||
{(action): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
classList={{
|
||||
[styles.action]: true,
|
||||
[styles.actionDanger]: action.tone === "danger",
|
||||
}}
|
||||
onClick={() => handleActionSelect(action, target)}
|
||||
>
|
||||
<span class={styles.actionLabel}>{action.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user