${TERRAIN_OVERLAYS.map((option) => html`
> = {
@customElement("terrain-sculpt-toolbar")
export class SculptToolbar extends LitElement {
- static override styles = [
- viewportToolbarStyles,
- css`
- .step-count {
- position: absolute;
- inset-block-end: 1px;
- inset-inline-end: 2px;
- font-size: 9px;
- font-weight: 600;
- pointer-events: none;
- }
- `
- ];
+ static override styles = viewportToolbarStyles;
@property({ attribute: false })
declare settings: SculptSettings | null;
@@ -134,7 +121,7 @@ export class SculptToolbar extends LitElement {
const { tool, falloff } = settings.options;
return html`
-
+
${this.#renderHistory()}
${SCULPT_TOOLS.map((option) => html`
@@ -259,27 +246,23 @@ export class SculptToolbar extends LitElement {
data-tool="undo"
icon="undo"
label="Undo (Ctrl+Z)"
+ .count=${state.undoCount}
?disabled=${state.undoCount === 0}
@click=${() => history.undo(TERRAIN_HISTORY_SCOPE)}
- >${renderStepCount(state.undoCount)}
+ >
history.redo(TERRAIN_HISTORY_SCOPE)}
- >${renderStepCount(state.redoCount)}
+ >
`;
}
}
-function renderStepCount(
- count: number
-): TemplateResult | typeof nothing {
- return count === 0 ? nothing : html`${count}`;
-}
-
function formatMetres(
value: number
): string {
diff --git a/packages/editors/terrain/src/shared/toolbar.styles.ts b/packages/editors/terrain/src/shared/toolbar.styles.ts
index fb94a9e87..25e868d90 100644
--- a/packages/editors/terrain/src/shared/toolbar.styles.ts
+++ b/packages/editors/terrain/src/shared/toolbar.styles.ts
@@ -6,15 +6,6 @@ export const viewportToolbarStyles = css`
display: inline-flex;
}
- jolly-rail {
- gap: 0;
- height: auto;
- padding: 4px;
- border-radius: var(--jolly-radius-sm, 2px);
- background: var(--jolly-surface-raised);
- box-shadow: var(--jolly-shadow-floating);
- }
-
.group {
display: flex;
align-items: center;
diff --git a/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.styles.ts b/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.styles.ts
index cf673aae7..8e7b58260 100644
--- a/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.styles.ts
+++ b/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.styles.ts
@@ -24,15 +24,8 @@ export const editToolbarStyles = [viewportToolbarStyles, css`
jolly-rail {
--jolly-icon-button-size: var(--voxel-toolbar-button-size);
- flex-wrap: wrap;
- justify-content: center;
- align-items: center;
gap: var(--voxel-toolbar-row-gap) 0;
- height: auto;
padding: var(--voxel-toolbar-row-gap) 4px;
- border-radius: var(--jolly-radius-sm, 2px);
- background: var(--jolly-surface-raised);
- box-shadow: var(--jolly-shadow-floating);
}
.segment {
@@ -84,31 +77,6 @@ export const editToolbarStyles = [viewportToolbarStyles, css`
transition: translate 0s var(--voxel-segment-fade-out);
}
- .step-count {
- position: absolute;
- right: 0;
- bottom: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- min-width: 14px;
- height: 14px;
- padding: 0 3px;
- border-radius: 7px;
- background: var(--jolly-tone-teal);
- color: var(--jolly-surface-raised);
- font-size: 9px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
- line-height: 1;
- pointer-events: none;
- }
-
- .step-count > span {
- text-box: trim-both cap alphabetic;
- }
-
@media (prefers-reduced-motion: reduce) {
.segment,
.segment[inert],
diff --git a/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.ts b/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.ts
index 0d0db0160..b92b72320 100644
--- a/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.ts
+++ b/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.ts
@@ -131,6 +131,7 @@ export class EditToolbar extends EditingElement {
@@ -175,16 +176,18 @@ export class EditToolbar extends EditingElement {
data-tool="undo"
icon="undo"
label=${shortcuts.label("Undo", "edit.undo")}
+ .count=${this._undoDepth}
?disabled=${access.current.readOnly || this._undoDepth === 0}
@click=${this.#onUndo}
- >${renderStepCount(this._undoDepth)}
+ >
${renderStepCount(this._redoDepth)}
+ >
${depth}`;
-}
-
declare global {
interface HTMLElementTagNameMap {
"voxel-edit-toolbar": EditToolbar;
diff --git a/packages/ui/README.md b/packages/ui/README.md
index bfc7af295..ed6caba69 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -296,6 +296,8 @@ import {
- [`FieldBinding`](./docs/api/field/field-binding.md): binds a field in a Lit template to one
value.
+- [`FolderReorder`](./docs/api/containers/folder-reorder.md): moves `jolly-folder` elements by
+ their grip and commits the order to a model.
- [`SubscriptionController`](./docs/api/subscription-controller.md): keeps a host subscribed to one
source while connected.
- [`LazyTemplate`](./docs/api/lazy-template.md): renders part of a template once the module that
diff --git a/packages/ui/docs/api/containers/folder-reorder.md b/packages/ui/docs/api/containers/folder-reorder.md
new file mode 100644
index 000000000..7eab2e3fd
--- /dev/null
+++ b/packages/ui/docs/api/containers/folder-reorder.md
@@ -0,0 +1,67 @@
+# `FolderReorder`
+
+`FolderReorder` handles the grip of the `jolly-folder` elements in a list and
+asks its owner to commit the new order. A [reorderable pane](./pane.md) uses it
+for its slotted folders. Use it where folders are rendered from a model, such
+as the components of an inspected object.
+
+```ts
+import { FolderReorder } from "@jolly-pixel/ui";
+
+new FolderReorder(
+ host: ReactiveControllerHost,
+ options: FolderReorderOptions
+)
+
+interface FolderReorderOptions {
+ folders(): readonly Folder[]; // the committed order
+ zone(): HTMLElement; // drop area of a pointer drag
+ commit(order: readonly Folder[], folder: Folder): void;
+ announce(message: string): void;
+}
+```
+
+```ts
+interface FolderReorder {
+ onDrag(event: CustomEvent<{ folder: Folder; event: PointerEvent; }>): void;
+ onReorder(event: CustomEvent<{
+ folder: Folder;
+ command: "start" | "up" | "down" | "finish" | "cancel";
+ }>): void;
+ cancel(): void;
+}
+```
+
+Bind `onDrag` to `jolly-folder-drag` and `onReorder` to
+`jolly-folder-reorder` on an ancestor of the folders. Both stop propagation.
+Set `reorderable` on the folders yourself.
+
+- A pointer drag shows the folder header as a ghost and an insertion line in
+ `zone()`. The folder is `dragging` while it lasts.
+- Space grabs the folder and sets its `grabbed`. ArrowUp and ArrowDown preview
+ the move through `style.order`, Space drops and Escape cancels. The
+ `style.order` values from before the grab come back on drop or cancel,
+ before `commit` runs.
+- `commit` receives the whole order and the moved folder, only when the order
+ changed. Write it to the model; the controller does not reorder the DOM.
+- `announce` receives the grab, position, drop and cancel messages. Show them
+ in an `aria-live` region.
+- Only one gesture runs at a time. A grab or drag that starts during another
+ is ignored, and the folder stays released.
+- A gesture is cancelled when the host disconnects, when `folders()` returns a
+ different list after a host update, or on `cancel()`.
+
+```ts
+#reorder = new FolderReorder(this, {
+ folders: () => [...this.renderRoot.querySelectorAll(".list > jolly-folder")],
+ zone: () => this.#list(),
+ commit: (order, folder) => this.model.move(
+ folder.key,
+ order[order.indexOf(folder) + 1]?.key
+ ),
+ announce: (message) => {
+ this.#announcement = message;
+ this.requestUpdate();
+ }
+});
+```
diff --git a/packages/ui/docs/api/containers/folder.md b/packages/ui/docs/api/containers/folder.md
index 2a152e12e..4c2a539e2 100644
--- a/packages/ui/docs/api/containers/folder.md
+++ b/packages/ui/docs/api/containers/folder.md
@@ -16,12 +16,13 @@ interface Folder {
key: string;
open: boolean; // default true
collapsible: boolean; // default true
- reorderable: boolean; // shows a grip when its pane allows reordering
+ reorderable: boolean; // shows the grip, set by a reorderable pane
flush: boolean; // no content indent
storageKey: string; // attribute storage-key
storage: StorageAdapter; // property only, default LocalStorageAdapter
dragging: boolean; // set by its pane during a reorder preview
+ grabbed: boolean; // set by its pane while moved with the keyboard
}
```
@@ -30,9 +31,29 @@ interface Folder {
```ts
interface FolderEvents {
"jolly-toggle": { open: boolean; };
+ "jolly-folder-drag": { folder: Folder; event: PointerEvent; };
+ "jolly-folder-reorder": {
+ folder: Folder;
+ command: "start" | "up" | "down" | "finish" | "cancel";
+ };
}
```
+## Reordering
+
+The grip asks for a move and leaves the order to whoever handles the events.
+A [reorderable pane](./pane.md) handles them for the folders slotted in it.
+Elsewhere, set `reorderable` and hand the events to a
+[`FolderReorder`](./folder-reorder.md):
+
+- A primary-button press on the grip emits `jolly-folder-drag` with the
+ `pointerdown` event, only while `reorderable`.
+- Space on the grip emits `start`, or `finish` while `grabbed`. While
+ `grabbed`, ArrowUp and ArrowDown emit `up` and `down`, and Escape emits
+ `cancel`.
+- The folder never sets `grabbed` itself. The handler sets it on `start` and
+ clears it when the move ends.
+
## Slots and parts
- Slots: default (content), `actions` (header).
diff --git a/packages/ui/docs/api/containers/rail.md b/packages/ui/docs/api/containers/rail.md
index e35265b18..9a9fb61c3 100644
--- a/packages/ui/docs/api/containers/rail.md
+++ b/packages/ui/docs/api/containers/rail.md
@@ -14,5 +14,10 @@ only, with no toolbar role.
```ts
interface Rail {
orientation: "horizontal" | "vertical"; // default "vertical"
+ floating: boolean;
}
```
+
+`floating` draws the rail as a raised strip with a shadow, for a toolbar over
+a viewport. A horizontal floating rail wraps its items onto more rows when it
+runs out of width.
diff --git a/packages/ui/docs/api/controls/button.md b/packages/ui/docs/api/controls/button.md
index 9c7472de7..3f5b2a021 100644
--- a/packages/ui/docs/api/controls/button.md
+++ b/packages/ui/docs/api/controls/button.md
@@ -17,6 +17,7 @@ interface Button {
disabled: boolean;
label: string; // accessible name
iconOnly: boolean; // attribute icon-only
+ pressed: boolean | undefined; // property only, unset: not a toggle
}
type ButtonVariant = "default" | "accent" | "danger";
@@ -25,3 +26,16 @@ type ButtonVariant = "default" | "accent" | "danger";
- The default slot holds the visible content.
- `label` names an icon-only button but shows no tooltip. Add the global
`title` attribute for one.
+- Setting `pressed` makes the button a toggle: it sets `aria-pressed`, and
+ the icon fades while `pressed` is `false`. A click does not flip it; set it
+ from the `click` handler.
+
+```ts
+html` setVisible(!visible)}
+>`
+```
diff --git a/packages/ui/docs/api/controls/tool-button.md b/packages/ui/docs/api/controls/tool-button.md
index 8bfab2f96..c6107308a 100644
--- a/packages/ui/docs/api/controls/tool-button.md
+++ b/packages/ui/docs/api/controls/tool-button.md
@@ -20,10 +20,14 @@ interface ToolButton {
disabled: boolean;
flyoutSide: "above" | "below" | "left" | "right"; // attribute flyout-side, default "right"
open: boolean;
+ count: number; // corner badge, hidden at 0
}
```
- A toned button tints its hover and `active` fill with the tone.
+- `count` shows a number in the bottom-right corner, such as the undo depth.
+ The badge is hidden from assistive technology: put anything it must hear in
+ `label`.
- The tooltip opens on `flyout-side`.
## Methods
@@ -50,5 +54,5 @@ to `flyout-side`.
## Styling
-- Parts: `button`, `notch`, `tooltip`, `flyout`.
+- Parts: `button`, `count`, `notch`, `tooltip`, `flyout`.
- `--jolly-tool-button-size` (`36px`), `--jolly-tool-button-gap` (`6px`).
diff --git a/packages/ui/src/containers/folder/Folder.ts b/packages/ui/src/containers/folder/Folder.ts
index 109d615ef..de5e34da0 100644
--- a/packages/ui/src/containers/folder/Folder.ts
+++ b/packages/ui/src/containers/folder/Folder.ts
@@ -6,8 +6,7 @@ import {
} from "lit";
import {
customElement,
- property,
- state
+ property
} from "lit/decorators.js";
// Import Internal Dependencies
@@ -64,6 +63,9 @@ export class Folder extends LitElement implements Searchable {
@property({ type: Boolean, reflect: true })
declare dragging: boolean;
+ @property({ type: Boolean, reflect: true })
+ declare grabbed: boolean;
+
@property({ type: Boolean, reflect: true })
declare flush: boolean;
@@ -76,9 +78,6 @@ export class Folder extends LitElement implements Searchable {
@property({ attribute: false })
declare storage: StorageAdapter;
- @state()
- declare _reordering: boolean;
-
#persistenceKey = "";
#state = new NamespacedStore({
isManaged: () => this.closest("jolly-dock-layout") !== null,
@@ -110,10 +109,10 @@ export class Folder extends LitElement implements Searchable {
this.collapsible = true;
this.reorderable = false;
this.dragging = false;
+ this.grabbed = false;
this.flush = false;
this.storageKey = "";
this.storage = defaultStorageAdapter();
- this._reordering = false;
}
override connectedCallback(): void {
@@ -140,7 +139,7 @@ export class Folder extends LitElement implements Searchable {
class="grip"
type="button"
aria-label=${`Reorder ${this.label}`}
- aria-pressed=${String(this._reordering)}
+ aria-pressed=${String(this.grabbed)}
@pointerdown=${this.#onGripPointerDown}
@keydown=${this.#onGripKeyDown}
>
@@ -229,14 +228,11 @@ export class Folder extends LitElement implements Searchable {
) => {
if (event.key === " ") {
event.preventDefault();
- this._reordering = !this._reordering;
- this.#emitReorder(
- this._reordering ? "start" : "finish"
- );
+ this.#emitReorder(this.grabbed ? "finish" : "start");
return;
}
- if (!this._reordering) {
+ if (!this.grabbed) {
return;
}
@@ -252,9 +248,6 @@ export class Folder extends LitElement implements Searchable {
event.preventDefault();
this.#emitReorder(command);
- if (command === "cancel") {
- this._reordering = false;
- }
};
#onGripPointerDown = (
diff --git a/packages/ui/src/containers/folder/FolderReorder.ts b/packages/ui/src/containers/folder/FolderReorder.ts
new file mode 100644
index 000000000..9d68aadec
--- /dev/null
+++ b/packages/ui/src/containers/folder/FolderReorder.ts
@@ -0,0 +1,268 @@
+// Import Third-party Dependencies
+import type {
+ ReactiveController,
+ ReactiveControllerHost
+} from "lit";
+
+// Import Internal Dependencies
+import type { ContainerEventMap } from "../events.ts";
+import type { Folder } from "./Folder.ts";
+import { headerGhost } from "../../interaction/drag/dragGhost.ts";
+import {
+ startDragSession,
+ verticalInsertionLine,
+ type DragSessionHandle
+} from "../../interaction/drag/DragSession.ts";
+
+type FolderDragEvent = CustomEvent;
+type FolderReorderEvent = CustomEvent;
+
+// CONSTANTS
+const kFolderZone = "folders";
+
+export interface FolderReorderOptions {
+ folders(): readonly Folder[];
+ zone(): HTMLElement;
+ commit(order: readonly Folder[], folder: Folder): void;
+ announce(message: string): void;
+}
+
+interface DragGesture {
+ kind: "drag";
+ start: readonly Folder[];
+ session: DragSessionHandle;
+}
+
+interface KeyboardGrab {
+ kind: "grab";
+ folder: Folder;
+ start: readonly Folder[];
+ order: Folder[];
+ styles: string[];
+}
+
+export class FolderReorder implements ReactiveController {
+ readonly #options: FolderReorderOptions;
+ #gesture: DragGesture | KeyboardGrab | null = null;
+
+ constructor(
+ host: ReactiveControllerHost,
+ options: FolderReorderOptions
+ ) {
+ host.addController(this);
+ this.#options = options;
+ }
+
+ hostUpdated(): void {
+ const gesture = this.#gesture;
+ if (
+ gesture !== null &&
+ !sameFolders(gesture.start, this.#options.folders())
+ ) {
+ this.cancel();
+ }
+ }
+
+ hostDisconnected(): void {
+ this.cancel();
+ }
+
+ cancel(): void {
+ const gesture = this.#gesture;
+ if (gesture?.kind === "drag") {
+ this.#gesture = null;
+ gesture.session.cancel();
+ }
+ else if (gesture?.kind === "grab") {
+ this.#release(gesture);
+ }
+ }
+
+ onDrag = (
+ event: FolderDragEvent
+ ) => {
+ event.stopPropagation();
+ const { folder, event: pointer } = event.detail;
+ const folders = [...this.#options.folders()];
+ const from = folders.indexOf(folder);
+ if (this.#gesture !== null || from === -1) {
+ return;
+ }
+
+ const bounds = readRect(this.#options.zone());
+ const candidates = folders.map((item) => {
+ const rect = item.getBoundingClientRect();
+
+ return { start: rect.y, size: rect.height };
+ });
+ const session = startDragSession({
+ source: folder,
+ event: pointer,
+ handle: pointer.currentTarget instanceof HTMLElement ?
+ pointer.currentTarget :
+ folder,
+ ghostLabel: folder.label,
+ ghostElement: () => {
+ const ghost = headerGhost(folder);
+ ghost.label = folder.label;
+
+ return ghost;
+ },
+ zones: () => [{
+ id: kFolderZone,
+ rect: bounds,
+ candidates,
+ axis: "y",
+ line: (index) => verticalInsertionLine(bounds, candidates, index)
+ }],
+ onStart: () => {
+ folder.dragging = true;
+ },
+ onCommit: (result) => {
+ if (result.zone !== null) {
+ this.#drop(folders, from, result.index);
+ }
+ },
+ onEnd: () => {
+ folder.dragging = false;
+ if (this.#gesture?.kind === "drag" && this.#gesture.session === session) {
+ this.#gesture = null;
+ }
+ }
+ });
+ this.#gesture = {
+ kind: "drag",
+ start: folders,
+ session
+ };
+ };
+
+ onReorder = (
+ event: FolderReorderEvent
+ ) => {
+ event.stopPropagation();
+ const { folder, command } = event.detail;
+ if (command === "start") {
+ this.#grab(folder);
+
+ return;
+ }
+
+ const grab = this.#gesture;
+ if (grab?.kind !== "grab" || grab.folder !== folder) {
+ return;
+ }
+
+ switch (command) {
+ case "up":
+ case "down":
+ this.#step(grab, command === "up" ? -1 : 1);
+ break;
+ case "cancel":
+ this.#release(grab);
+ this.#options.announce(`${folder.label} movement cancelled`);
+ break;
+ case "finish":
+ this.#finish(grab);
+ break;
+ }
+ };
+
+ #grab(
+ folder: Folder
+ ): void {
+ const folders = [...this.#options.folders()];
+ if (this.#gesture !== null || !folders.includes(folder)) {
+ return;
+ }
+
+ this.#gesture = {
+ kind: "grab",
+ folder,
+ start: folders,
+ order: [...folders],
+ styles: folders.map((item) => item.style.order)
+ };
+ folder.grabbed = true;
+ this.#options.announce(`${folder.label} grabbed`);
+ }
+
+ #step(
+ grab: KeyboardGrab,
+ step: -1 | 1
+ ): void {
+ const { folder, order } = grab;
+ const index = order.indexOf(folder);
+ const next = index + step;
+ if (next < 0 || next >= order.length) {
+ return;
+ }
+
+ [order[index], order[next]] = [order[next], order[index]];
+ for (const [position, item] of order.entries()) {
+ item.style.order = String(position);
+ }
+ this.#options.announce(
+ `${folder.label}, position ${next + 1} of ${order.length}`
+ );
+ }
+
+ #finish(
+ grab: KeyboardGrab
+ ): void {
+ this.#release(grab);
+ if (!sameFolders(grab.start, grab.order)) {
+ this.#options.commit(grab.order, grab.folder);
+ }
+ this.#options.announce(`${grab.folder.label} dropped`);
+ }
+
+ #drop(
+ folders: readonly Folder[],
+ from: number,
+ insertion: number
+ ): void {
+ const to = insertion > from ? insertion - 1 : insertion;
+ if (to === from) {
+ return;
+ }
+
+ const folder = folders[from];
+ const order = folders.toSpliced(from, 1).toSpliced(to, 0, folder);
+ this.#options.commit(order, folder);
+ this.#options.announce(
+ `${folder.label}, position ${to + 1} of ${order.length}`
+ );
+ }
+
+ #release(
+ grab: KeyboardGrab
+ ): void {
+ this.#gesture = null;
+ grab.folder.grabbed = false;
+ for (const [index, item] of grab.start.entries()) {
+ item.style.order = grab.styles[index];
+ }
+ }
+}
+
+function sameFolders(
+ left: readonly Folder[],
+ right: readonly Folder[]
+): boolean {
+ return left.length === right.length &&
+ left.every((folder, index) => folder === right[index]);
+}
+
+function readRect(
+ element: Element
+) {
+ const rect = element.getBoundingClientRect();
+
+ return {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height
+ };
+}
diff --git a/packages/ui/src/containers/pane/FolderListController.ts b/packages/ui/src/containers/pane/FolderListController.ts
index 87b563f23..324f978e8 100644
--- a/packages/ui/src/containers/pane/FolderListController.ts
+++ b/packages/ui/src/containers/pane/FolderListController.ts
@@ -1,19 +1,13 @@
// Import Third-party Dependencies
-import type {
- ReactiveController,
- ReactiveControllerHost
-} from "lit";
+import type { ReactiveControllerHost } from "lit";
// Import Internal Dependencies
-import { emitContainerEvent } from "../events.ts";
-import type { Folder } from "../folder/Folder.ts";
-import { readDetail } from "../../dom.ts";
-import { headerGhost } from "../../interaction/drag/dragGhost.ts";
import {
- startDragSession,
- verticalInsertionLine,
- type DragSessionHandle
-} from "../../interaction/drag/DragSession.ts";
+ emitContainerEvent,
+ type ContainerEventMap
+} from "../events.ts";
+import type { Folder } from "../folder/Folder.ts";
+import { FolderReorder } from "../folder/FolderReorder.ts";
import { NamespacedStore } from "../../storage/NamespacedStore.ts";
import type { StorageAdapter } from "../../storage/StorageAdapter.ts";
import {
@@ -21,24 +15,11 @@ import {
resolveOrder
} from "../../storage/keys.ts";
-// CONSTANTS
-const kFolderZone = "folders";
-
interface FolderEntry {
folder: Folder;
key: string;
}
-interface FolderReorderDetail {
- folder: Folder;
- command: "cancel" | "down" | "finish" | "start" | "up";
-}
-
-interface FolderDragDetail {
- folder: Folder;
- event: PointerEvent;
-}
-
export interface FolderListOptions {
content(): HTMLElement;
contentSlot(): HTMLSlotElement;
@@ -50,28 +31,30 @@ export interface FolderListOptions {
discovered(): void;
}
-export class FolderListController implements ReactiveController {
+export class FolderListController {
readonly #options: FolderListOptions;
readonly #store: NamespacedStore;
+ readonly #reorder: FolderReorder;
#entries: FolderEntry[] = [];
- #startOrder: string[] | null = null;
- #session: DragSessionHandle | null = null;
constructor(
host: ReactiveControllerHost,
options: FolderListOptions
) {
- host.addController(this);
this.#options = options;
this.#store = new NamespacedStore({
namespace: () => options.namespace(),
storage: () => options.storage()
});
- }
-
- hostDisconnected(): void {
- this.#session?.cancel();
- this.#session = null;
+ this.#reorder = new FolderReorder(host, {
+ folders: () => this.#entries.map(({ folder }) => folder),
+ zone: () => options.content(),
+ commit: (order) => {
+ this.#applyOrder(({ folder }) => order.indexOf(folder));
+ this.#commitOrder();
+ },
+ announce: (message) => options.announce(message)
+ });
}
states(): Record {
@@ -93,6 +76,7 @@ export class FolderListController implements ReactiveController {
}
onContentChange = () => {
+ this.#reorder.cancel();
const folders = this.#options.contentSlot()
.assignedElements({ flatten: true })
.filter((element): element is Folder => element.tagName === "JOLLY-FOLDER");
@@ -112,10 +96,11 @@ export class FolderListController implements ReactiveController {
}
}
- this.#applyOrder(resolveOrder(
+ const order = resolveOrder(
parseOrder(this.#store.readJson("order")),
this.#keys()
- ));
+ );
+ this.#applyOrder(({ key }) => order.indexOf(key));
this.#options.discovered();
};
@@ -131,166 +116,31 @@ export class FolderListController implements ReactiveController {
};
onFolderDrag = (
- event: Event
+ event: CustomEvent
) => {
- if (!this.#options.reorderable()) {
- return;
+ if (this.#options.reorderable()) {
+ this.#reorder.onDrag(event);
}
-
- event.stopPropagation();
- const detail = readDetail(event);
- if (detail === null || this.#session !== null) {
- return;
- }
-
- const ordered = this.#entries;
- const from = ordered.findIndex(
- ({ folder }) => folder === detail.folder
- );
- if (from === -1) {
- return;
- }
-
- const bounds = readRect(this.#options.content());
- const candidates = ordered.map(({ folder }) => {
- const rect = folder.getBoundingClientRect();
-
- return { start: rect.y, size: rect.height };
- });
-
- this.#session = startDragSession({
- source: detail.folder,
- event: detail.event,
- handle: detail.event.currentTarget instanceof HTMLElement ?
- detail.event.currentTarget :
- detail.folder,
- ghostLabel: detail.folder.label,
- ghostElement: () => {
- const ghost = headerGhost(detail.folder);
- ghost.label = detail.folder.label;
-
- return ghost;
- },
- zones: () => [{
- id: kFolderZone,
- rect: bounds,
- candidates,
- axis: "y",
- line: (index) => verticalInsertionLine(bounds, candidates, index)
- }],
- onStart: () => {
- detail.folder.dragging = true;
- },
- onCommit: (result) => {
- if (result.zone !== null) {
- this.#move(from, result.index);
- }
- },
- onEnd: () => {
- this.#session = null;
- detail.folder.dragging = false;
- }
- });
};
onReorderCommand = (
- event: Event
+ event: CustomEvent
) => {
- if (!this.#options.reorderable()) {
- return;
+ if (this.#options.reorderable()) {
+ this.#reorder.onReorder(event);
}
-
- event.stopPropagation();
- const detail = readDetail(event);
- if (detail === null) {
- return;
- }
-
- const order = this.#keys();
- const index = this.#entries.findIndex(
- ({ folder }) => folder === detail.folder
- );
- if (index === -1) {
- return;
- }
-
- if (detail.command === "start") {
- this.#startOrder = order;
- this.#options.announce(`${detail.folder.label} grabbed`);
-
- return;
- }
- if (detail.command === "cancel") {
- if (this.#startOrder !== null) {
- this.#applyOrder(this.#startOrder);
- }
- this.#startOrder = null;
- this.#options.announce(
- `${detail.folder.label} movement cancelled`
- );
-
- return;
- }
- if (detail.command === "finish") {
- this.#commitOrder();
- this.#startOrder = null;
- this.#options.announce(
- `${detail.folder.label} dropped`
- );
-
- return;
- }
-
- const next = Math.min(
- Math.max(index + (detail.command === "up" ? -1 : 1), 0),
- order.length - 1
- );
- if (index === next) {
- return;
- }
-
- [order[index], order[next]] = [order[next], order[index]];
- this.#applyOrder(order);
- this.#options.announce(
- `${detail.folder.label}, position ${next + 1} of ${order.length}`
- );
};
- #move(
- from: number,
- insertion: number
- ): void {
- const order = this.#keys();
- const to = insertion > from ? insertion - 1 : insertion;
- if (to === from || to < 0 || to >= order.length) {
- return;
- }
-
- const [key] = order.splice(from, 1);
- order.splice(to, 0, key);
- this.#applyOrder(order);
- this.#commitOrder();
- this.#options.announce(
- `${this.#entries[from]?.folder.label ?? "Folder"}, position ` +
- `${to + 1} of ${order.length}`
- );
- }
-
#keys(): string[] {
return this.#entries.map(({ key }) => key);
}
#applyOrder(
- order: readonly string[]
+ rank: (entry: FolderEntry) => number
): void {
- const entries = new Map(
- this.#entries.map((entry) => [entry.key, entry])
+ this.#entries = this.#entries.toSorted(
+ (left, right) => rank(left) - rank(right)
);
- this.#entries = order.flatMap((key) => {
- const entry = entries.get(key);
-
- return entry === undefined ? [] : [entry];
- });
for (const [index, { folder }] of this.#entries.entries()) {
folder.style.order = String(index);
}
@@ -332,19 +182,6 @@ function folderKeys(
});
}
-function readRect(
- element: Element
-) {
- const rect = element.getBoundingClientRect();
-
- return {
- x: rect.x,
- y: rect.y,
- width: rect.width,
- height: rect.height
- };
-}
-
function parseOrder(
value: unknown
): string[] {
diff --git a/packages/ui/src/containers/rail/Rail.ts b/packages/ui/src/containers/rail/Rail.ts
index bfdd776dd..ead728d4a 100644
--- a/packages/ui/src/containers/rail/Rail.ts
+++ b/packages/ui/src/containers/rail/Rail.ts
@@ -35,15 +35,29 @@ export class Rail extends LitElement {
width: auto;
height: calc(var(--jolly-icon-button-size, 32px) + (var(--jolly-space-1, 4px) * 2));
}
+
+ :host([floating]) {
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--jolly-space-1, 4px) 0;
+ height: auto;
+ border-radius: var(--jolly-radius-sm, 2px);
+ background: var(--jolly-surface-raised);
+ box-shadow: var(--jolly-shadow-floating);
+ }
`;
@property({ type: String, reflect: true })
declare orientation: RailOrientation;
+ @property({ type: Boolean, reflect: true })
+ declare floating: boolean;
+
constructor() {
super();
this.orientation = "vertical";
+ this.floating = false;
}
override render(): TemplateResult {
diff --git a/packages/ui/src/controls/Button.styles.ts b/packages/ui/src/controls/Button.styles.ts
index d91071af8..cb95b8f35 100644
--- a/packages/ui/src/controls/Button.styles.ts
+++ b/packages/ui/src/controls/Button.styles.ts
@@ -83,6 +83,10 @@ export const buttonStyles = css`
background: var(--jolly-invalid-bg-focus);
}
+ button[aria-pressed="false"] jolly-icon {
+ opacity: 0.4;
+ }
+
:host([disabled]) button {
opacity: 0.5;
cursor: default;
diff --git a/packages/ui/src/controls/Button.ts b/packages/ui/src/controls/Button.ts
index 958076ea4..32693de5d 100644
--- a/packages/ui/src/controls/Button.ts
+++ b/packages/ui/src/controls/Button.ts
@@ -61,6 +61,9 @@ export class Button extends LitElement {
})
declare iconOnly: boolean;
+ @property({ attribute: false })
+ declare pressed?: boolean;
+
constructor() {
super();
@@ -76,6 +79,7 @@ export class Button extends LitElement {
type="button"
?disabled=${this.disabled}
aria-label=${this.label === "" ? nothing : this.label}
+ aria-pressed=${this.pressed === undefined ? nothing : String(this.pressed)}
>
${this.icon === undefined
? nothing
diff --git a/packages/ui/src/controls/ToolButton.styles.ts b/packages/ui/src/controls/ToolButton.styles.ts
index 253a434ab..9362d2b75 100644
--- a/packages/ui/src/controls/ToolButton.styles.ts
+++ b/packages/ui/src/controls/ToolButton.styles.ts
@@ -120,6 +120,30 @@ export const toolButtonStyles = css`
border-right-color: currentcolor;
}
+ .count {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ min-width: 14px;
+ height: 14px;
+ padding: 0 3px;
+ border-radius: 7px;
+ background: var(--jolly-tone-teal);
+ color: var(--jolly-surface-raised);
+ font-size: 9px;
+ font-weight: 700;
+ line-height: 1;
+ pointer-events: none;
+ }
+
+ .count > span {
+ text-box: trim-both cap alphabetic;
+ }
+
.tooltip,
.flyout {
position: absolute;
diff --git a/packages/ui/src/controls/ToolButton.ts b/packages/ui/src/controls/ToolButton.ts
index 9e4646f2a..3f4c7863f 100644
--- a/packages/ui/src/controls/ToolButton.ts
+++ b/packages/ui/src/controls/ToolButton.ts
@@ -70,6 +70,9 @@ export class ToolButton extends LitElement {
@property({ type: Boolean, reflect: true })
declare open: boolean;
+ @property({ type: Number })
+ declare count: number;
+
@state()
declare _hasFlyout: boolean;
@@ -92,6 +95,7 @@ export class ToolButton extends LitElement {
this.disabled = false;
this.flyoutSide = ToolButton.Defaults.flyoutSide;
this.open = false;
+ this.count = 0;
this._hasFlyout = false;
}
@@ -160,6 +164,9 @@ export class ToolButton extends LitElement {
nothing :
html``}
+ ${this.count > 0 ?
+ html`${this.count}` :
+ nothing}
${this._hasFlyout ? html`` : nothing}
${this.label === "" ?
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index f11155764..8ae9dd842 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -182,6 +182,7 @@ export type * from "./containers/dock/Dock.ts";
export type * from "./containers/dock/DockLayout.ts";
export type * from "./containers/floating/Floating.ts";
export type * from "./containers/folder/Folder.ts";
+export * from "./containers/folder/FolderReorder.ts";
export type {
PaneElement
} from "./containers/pane/Pane.ts";
diff --git a/packages/ui/test/e2e/containers/folder/folder.e2e.ts b/packages/ui/test/e2e/containers/folder/folder.e2e.ts
index 9c5b078e7..42b8983a7 100644
--- a/packages/ui/test/e2e/containers/folder/folder.e2e.ts
+++ b/packages/ui/test/e2e/containers/folder/folder.e2e.ts
@@ -291,4 +291,26 @@ test.describe("Folder reorder", () => {
await reloadGallery(page);
await expect.poll(() => visualOrder(folders)).toEqual(moved);
});
+
+ test("a second grip stays released during a grab, and Escape restores the order", async({ page }) => {
+ const folders = page.locator("jolly-pane > jolly-folder");
+ const grabbed = folders.first().locator(".grip");
+ const other = folders.nth(2).locator(".grip");
+
+ await grabbed.focus();
+ await grabbed.press("Space");
+ await grabbed.press("ArrowDown");
+ await expect(grabbed).toHaveAttribute("aria-pressed", "true");
+ await expect.poll(() => visualOrder(folders))
+ .toEqual(["Material", "Transform", "Physics"]);
+
+ await other.focus();
+ await other.press("Space");
+ await expect(other).toHaveAttribute("aria-pressed", "false");
+
+ await grabbed.focus();
+ await grabbed.press("Escape");
+ await expect(grabbed).toHaveAttribute("aria-pressed", "false");
+ await expect.poll(() => visualOrder(folders)).toEqual(kDefaultOrder);
+ });
});