Restructure j/k navigation to support headlines (#707)

* Accessibility fixes

* Restructure j/k navigation to support headlines

* Focus modal content when open

* Further focus adjustments

* Fix tests

* Remove unused functions
This commit is contained in:
Jonatan Kłosko 2021-11-16 21:57:10 +01:00 committed by GitHub
parent 4d92aeba2e
commit f64dd0ea90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 568 additions and 396 deletions

View file

@ -209,7 +209,7 @@
}
.menu-item {
@apply w-full flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 whitespace-nowrap;
@apply w-full flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 focus:bg-gray-50 whitespace-nowrap;
}
/* Boxes */

View file

@ -1,10 +1,15 @@
body {
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
}
/* Remove the default outline on focused elements */
:focus,
button:focus {
outline: none;
}
body {
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
menu {
margin: 0;
padding: 0;
}

View file

@ -44,6 +44,22 @@ solely client-side operations.
@apply hidden;
}
[data-element="notebook-headline"]:hover [data-element="heading"],
[data-element="section-headline"]:hover [data-element="heading"] {
@apply border-blue-200;
}
[data-element="notebook-headline"][data-js-focused] [data-element="heading"],
[data-element="section-headline"][data-js-focused] [data-element="heading"] {
@apply border-blue-300;
}
[data-element="section-headline"]:not(:hover):not([data-js-focused])
[data-element="heading"]
+ [data-element="section-actions"]:not(:focus-within) {
@apply hidden;
}
[data-element="cell"][data-js-focused] {
@apply border-blue-300 border-opacity-100;
}
@ -125,12 +141,6 @@ solely client-side operations.
@apply text-gray-50 bg-gray-700;
}
[data-element="section-headline"]:not(:hover)
[data-element="section-name"]:not(:focus)
+ [data-element="section-actions"]:not(:focus-within) {
@apply hidden;
}
[data-element="clients-list-item"]:not([data-js-followed])
[data-meta="unfollow"] {
@apply hidden;

View file

@ -11,7 +11,7 @@ import "phoenix_html";
import { Socket } from "phoenix";
import topbar from "topbar";
import { LiveSocket } from "phoenix_live_view";
import ContentEditable from "./content_editable";
import Headline from "./headline";
import Cell from "./cell";
import Session from "./session";
import FocusOnUpdate from "./focus_on_update";
@ -28,7 +28,7 @@ import morphdomCallbacks from "./morphdom_callbacks";
import { loadUserData } from "./lib/user";
const hooks = {
ContentEditable,
Headline,
Cell,
Session,
FocusOnUpdate,

View file

@ -13,8 +13,10 @@ import scrollIntoView from "scroll-into-view-if-needed";
*
* Configuration:
*
* * `data-focusable-id` - an identifier for the focus/insert navigation
* * `data-cell-id` - id of the cell being edited
* * `data-type` - type of the cell
* * `data-session-path` - root path to the current session
*/
const Cell = {
mounted() {
@ -136,16 +138,23 @@ const Cell = {
const input = getInput(this);
input.addEventListener("blur", (event) => {
if (this.state.isFocused && this.state.insertMode) {
// We are still in the insert mode, so focus the input
// back once other handlers complete
setTimeout(() => {
// Wait for other handlers to complete and if still in insert
// force focus
setTimeout(() => {
if (this.state.isFocused && this.state.insertMode) {
input.focus();
}, 0);
}
}
}, 0);
});
}
this._unsubscribeFromNavigationEvents = globalPubSub.subscribe(
"navigation",
(event) => {
handleNavigationEvent(this, event);
}
);
this._unsubscribeFromCellsEvents = globalPubSub.subscribe(
"cells",
(event) => {
@ -155,6 +164,7 @@ const Cell = {
},
destroyed() {
this._unsubscribeFromNavigationEvents();
this._unsubscribeFromCellsEvents();
if (this.state.liveEditor) {
@ -184,24 +194,31 @@ function getInput(hook) {
}
/**
* Handles client-side cells event.
* Handles client-side navigation event.
*/
function handleCellsEvent(hook, event) {
if (event.type === "cell_focused") {
handleCellFocused(hook, event.cellId, event.scroll);
function handleNavigationEvent(hook, event) {
if (event.type === "element_focused") {
handleElementFocused(hook, event.focusableId, event.scroll);
} else if (event.type === "insert_mode_changed") {
handleInsertModeChanged(hook, event.enabled);
} else if (event.type === "cell_moved") {
handleCellMoved(hook, event.cellId);
} else if (event.type === "cell_upload") {
handleCellUpload(hook, event.cellId, event.url);
} else if (event.type === "location_report") {
handleLocationReport(hook, event.client, event.report);
}
}
function handleCellFocused(hook, cellId, scroll) {
if (hook.props.cellId === cellId) {
/**
* Handles client-side cells event.
*/
function handleCellsEvent(hook, event) {
if (event.type === "cell_moved") {
handleCellMoved(hook, event.cellId);
} else if (event.type === "cell_upload") {
handleCellUpload(hook, event.cellId, event.url);
}
}
function handleElementFocused(hook, focusableId, scroll) {
if (hook.props.cellId === focusableId) {
hook.state.isFocused = true;
hook.el.setAttribute("data-js-focused", "true");
if (scroll) {
@ -277,7 +294,7 @@ function handleLocationReport(hook, client, report) {
return;
}
if (hook.props.cellId === report.cellId && report.selection) {
if (hook.props.cellId === report.focusableId && report.selection) {
hook.state.liveEditor.updateUserSelection(client, report.selection);
} else {
hook.state.liveEditor.removeUserSelection(client);
@ -291,7 +308,7 @@ function broadcastSelection(hook, selection = null) {
if (hook.state.isFocused && hook.state.insertMode) {
globalPubSub.broadcast("session", {
type: "cursor_selection_changed",
cellId: hook.props.cellId,
focusableId: hook.props.cellId,
selection,
});
}

View file

@ -1,65 +0,0 @@
import { getAttributeOrThrow } from "../lib/attribute";
/**
* A hook used on [contenteditable] elements to update the specified
* attribute with the element text.
*
* Configuration:
*
* * `data-update-attribute` - the name of the attribute to update when content changes
*/
const ContentEditable = {
mounted() {
this.props = getProps(this);
this.__updateAttribute();
// Set the specified attribute on every content change
this.el.addEventListener("input", (event) => {
this.__updateAttribute();
});
// Make sure only plain text is pasted
this.el.addEventListener("paste", (event) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
});
// Unfocus the element on Enter or Escape
this.el.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === "Escape") {
this.el.blur();
}
});
// While the element is focused, ignore the incoming changes
this.el.addEventListener("focus", (event) => {
this.el.setAttribute("phx-update", "ignore");
});
this.el.addEventListener("blur", (event) => {
this.el.removeAttribute("phx-update");
});
},
updated() {
this.props = getProps(this);
// The element has been re-rendered so we have to add the attribute back
this.__updateAttribute();
},
__updateAttribute() {
const value = this.el.innerText.trim();
this.el.setAttribute(this.props.attribute, value);
},
};
function getProps(hook) {
return {
attribute: getAttributeOrThrow(hook.el, "data-update-attribute"),
};
}
export default ContentEditable;

137
assets/js/headline/index.js Normal file
View file

@ -0,0 +1,137 @@
import { getAttributeOrThrow } from "../lib/attribute";
import { globalPubSub } from "../lib/pub_sub";
import { smoothlyScrollToElement } from "../lib/utils";
/**
* A hook managing notebook/section headline.
*
* Similarly to cells the headline is focus/insert enabled.
*
* Configuration:
*
* * `data-focusable-id` - an identifier for the focus/insert navigation
* * `data-on-value-change` - name of the event pushed when the user edits heading value
* * `data-metadata` - additional value to send with the change event
*/
const Headline = {
mounted() {
this.props = getProps(this);
this.state = {
isFocused: false,
insertMode: false,
};
const heading = getHeading(this);
// Make sure only plain text is pasted
heading.addEventListener("paste", (event) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain").replace("\n", " ");
document.execCommand("insertText", false, text);
});
// Ignore enter
heading.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
}
});
heading.addEventListener("blur", (event) => {
// Wait for other handlers to complete and if still in insert
// force focus
setTimeout(() => {
if (this.state.isFocused && this.state.insertMode) {
heading.focus();
moveSelectionToEnd(heading);
}
}, 0);
});
this._unsubscribeFromNavigationEvents = globalPubSub.subscribe(
"navigation",
(event) => {
handleNavigationEvent(this, event);
}
);
},
updated() {
this.props = getProps(this);
},
destroyed() {
this._unsubscribeFromNavigationEvents();
},
};
function getProps(hook) {
return {
focusableId: getAttributeOrThrow(hook.el, "data-focusable-id"),
onValueChange: getAttributeOrThrow(hook.el, "data-on-value-change"),
metadata: getAttributeOrThrow(hook.el, "data-metadata"),
};
}
function handleNavigationEvent(hook, event) {
if (event.type === "element_focused") {
handleElementFocused(hook, event.focusableId, event.scroll);
} else if (event.type === "insert_mode_changed") {
handleInsertModeChanged(hook, event.enabled);
}
}
function handleElementFocused(hook, cellId, scroll) {
if (hook.props.focusableId === cellId) {
hook.state.isFocused = true;
hook.el.setAttribute("data-js-focused", "true");
if (scroll) {
smoothlyScrollToElement(hook.el);
}
} else if (hook.state.isFocused) {
hook.state.isFocused = false;
hook.el.removeAttribute("data-js-focused");
}
}
function handleInsertModeChanged(hook, insertMode) {
if (hook.state.isFocused) {
hook.state.insertMode = insertMode;
const heading = getHeading(hook);
if (hook.state.insertMode) {
// While in insert mode, ignore the incoming changes
hook.el.setAttribute("phx-update", "ignore");
heading.setAttribute("contenteditable", "true");
heading.focus();
moveSelectionToEnd(heading);
} else {
heading.removeAttribute("contenteditable");
hook.el.removeAttribute("phx-update");
hook.pushEvent(hook.props.onValueChange, {
value: headingValue(heading),
metadata: hook.props.metadata,
});
}
}
}
function getHeading(hook) {
return hook.el.querySelector(`[data-element="heading"]`);
}
function headingValue(heading) {
return heading.textContent.trim();
}
function moveSelectionToEnd(heading) {
const range = document.createRange();
range.selectNodeContents(heading);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
export default Headline;

View file

@ -33,7 +33,7 @@ import monaco from "../cell/live_editor/monaco";
*
* ## Navigation
*
* This hook handles focusing cells and moving the focus around,
* This hook handles focusing section titles, cells and moving the focus around,
* this is done purely on the client side because it is event-intensive
* and specific to this client only. The UI changes are handled by
* setting `data-js-*` attributes and using CSS accordingly (see assets/css/js_interop.css).
@ -60,8 +60,7 @@ const Session = {
mounted() {
this.props = getProps(this);
this.state = {
focusedCellId: null,
focusedSectionId: null,
focusedId: null,
focusedCellType: null,
insertMode: false,
keyBuffer: new KeyBuffer(),
@ -96,6 +95,19 @@ const Session = {
document.addEventListener("mousedown", this.handleDocumentMouseDown);
this.handleDocumentFocus = (event) => {
handleDocumentFocus(this, event);
};
// Note: the focus event doesn't bubble, so we register for the capture phase
document.addEventListener("focus", this.handleDocumentFocus, true);
this.handleDocumentClick = (event) => {
handleDocumentClick(this, event);
};
document.addEventListener("click", this.handleDocumentClick);
this.handleDocumentDoubleClick = (event) => {
handleDocumentDoubleClick(this, event);
};
@ -142,7 +154,6 @@ const Session = {
// DOM setup
updateSectionListHighlight();
focusNotebookNameIfNew();
// Server events
@ -195,9 +206,9 @@ const Session = {
this.handleEvent(
"location_report",
({ client_pid, cell_id, selection }) => {
({ client_pid, focusable_id, selection }) => {
const report = {
cellId: cell_id,
focusableId: focusable_id,
selection: decodeSelection(selection),
};
@ -225,8 +236,10 @@ const Session = {
destroyed() {
this._unsubscribeFromSessionEvents();
document.removeEventListener("keydown", this.handleDocumentKeyDown);
document.removeEventListener("keydown", this.handleDocumentKeyDown, true);
document.removeEventListener("mousedown", this.handleDocumentMouseDown);
document.removeEventListener("focus", this.handleDocumentFocus, true);
document.removeEventListener("click", this.handleDocumentClick);
document.removeEventListener("dblclick", this.handleDocumentDoubleClick);
setFavicon("favicon");
@ -265,7 +278,7 @@ function faviconForEvaluationStatus(evaluationStatus) {
*
* @typedef LocationReport
* @type {Object}
* @property {String|null} cellId
* @property {String|null} focusableId
* @property {monaco.Selection|null} selection
*/
@ -346,20 +359,22 @@ function handleDocumentKeyDown(hook, event) {
cancelFocusedCellEvaluation(hook);
} else if (keyBuffer.tryMatch(["0", "0"])) {
restartRuntime(hook);
} else if (keyBuffer.tryMatch(["Escape", "Escape"])) {
setFocusedEl(hook, null);
} else if (keyBuffer.tryMatch(["?"])) {
showShortcuts(hook);
} else if (
keyBuffer.tryMatch(["i"]) ||
(event.target === document.body &&
hook.state.focusedCellId &&
hook.state.focusedId &&
key === "Enter")
) {
cancelEvent(event);
enterInsertMode(hook);
} else if (keyBuffer.tryMatch(["j"])) {
moveCellFocus(hook, 1);
moveFocus(hook, 1);
} else if (keyBuffer.tryMatch(["k"])) {
moveCellFocus(hook, -1);
moveFocus(hook, -1);
} else if (keyBuffer.tryMatch(["J"])) {
moveFocusedCell(hook, 1);
} else if (keyBuffer.tryMatch(["K"])) {
@ -391,46 +406,61 @@ function handleDocumentMouseDown(hook, event) {
return;
}
// If the pencil icon is clicked, enter insert mode
if (event.target.closest(`[data-element="enable-insert-mode-button"]`)) {
setInsertMode(hook, true);
return;
// Find the focusable element, if one was clicked
const focusableEl = event.target.closest(`[data-focusable-id]`);
const focusableId = focusableEl ? focusableEl.dataset.focusableId : null;
const insertMode = editableElementClicked(event, focusableEl);
if (focusableId !== hook.state.focusedId) {
setFocusedEl(hook, focusableId, { scroll: false, focusElement: false });
}
// If a cell action is clicked, keep the focus as is
// If a cell action is clicked, keep the insert mode as is
if (event.target.closest(`[data-element="actions"]`)) {
return;
}
// Find the cell element, if one was clicked
const cell = event.target.closest(`[data-element="cell"]`);
const cellId = cell ? cell.dataset.cellId : null;
const insertMode = editableElementClicked(event, cell);
if (cellId !== hook.state.focusedCellId) {
setFocusedCell(hook, cellId, false);
}
// Depending on whether the click targets editor disable/enable insert mode
// Depending on whether the click targets editor or input disable/enable insert mode
if (hook.state.insertMode !== insertMode) {
setInsertMode(hook, insertMode);
}
}
function editableElementClicked(event, cell) {
if (cell) {
const editorContainer = cell.querySelector(
`[data-element="editor-container"]`
function editableElementClicked(event, element) {
if (element) {
const editableElement = element.querySelector(
`[data-element="editor-container"], [data-element="input"], [data-element="heading"]`
);
const input = cell.querySelector(`[data-element="input"]`);
const editableElement = editorContainer || input;
return editableElement.contains(event.target);
}
return false;
}
/**
* Focuses a focusable element if the user "tab"s anywhere into it.
*/
function handleDocumentFocus(hook, event) {
const focusableEl = event.target.closest(`[data-focusable-id]`);
if (focusableEl) {
const focusableId = focusableEl.dataset.focusableId;
if (focusableId !== hook.state.focusedId) {
setFocusedEl(hook, focusableId, { scroll: false, focusElement: false });
}
}
}
/**
* Enters insert mode when markdown edit action is clicked.
*/
function handleDocumentClick(hook, event) {
if (event.target.closest(`[data-element="enable-insert-mode-button"]`)) {
setInsertMode(hook, true);
}
}
/**
* Enters insert mode when a markdown cell is double-clicked.
*/
@ -439,7 +469,7 @@ function handleDocumentDoubleClick(hook, event) {
`[data-element="cell"][data-type="markdown"]`
);
if (markdownCell && hook.state.focusedCellId && !hook.state.insertMode) {
if (markdownCell && hook.state.focusedId && !hook.state.insertMode) {
setInsertMode(hook, true);
}
}
@ -508,8 +538,8 @@ function handleClientFollowToggleClick(hook, clientPid, clientListItem) {
function mirrorClientFocus(hook, clientPid) {
const locationReport = hook.state.lastLocationReportByClientPid[clientPid];
if (locationReport && locationReport.cellId) {
setFocusedCell(hook, locationReport.cellId);
if (locationReport && locationReport.focusableId) {
setFocusedEl(hook, locationReport.focusableId);
}
}
@ -520,7 +550,7 @@ function handleCellIndicatorsClick(hook, event) {
const button = event.target.closest(`[data-element="focus-cell-button"]`);
if (button) {
const cellId = button.getAttribute("data-target");
setFocusedCell(hook, cellId);
setFocusedEl(hook, cellId);
}
}
@ -532,22 +562,22 @@ function initializeFocus(hook) {
const hash = window.location.hash;
if (hash) {
if (hash.startsWith("#cell-")) {
const cellId = hash.replace(/^#cell-/, "");
if (getCellById(cellId)) {
setFocusedCell(hook, cellId);
}
} else {
// Explicitly scroll to the target element
// after the loading finishes
const htmlId = hash.replace(/^#/, "");
const element = document.getElementById(htmlId);
if (element) {
const htmlId = hash.replace(/^#/, "");
const element = document.getElementById(htmlId);
if (element) {
const focusableEl = elementelement.closest("[data-focusable-id]");
if (focusableEl) {
setFocusedEl(hook, focusableEl.dataset.focusableId);
} else {
// Explicitly scroll to the target element
// after the loading finishes
element.scrollIntoView();
}
}
} else if (hook.props.autofocusCellId) {
setFocusedCell(hook, hook.props.autofocusCellId, false);
setFocusedEl(hook, hook.props.autofocusCellId, { scroll: false });
setInsertMode(hook, true);
}
}
@ -613,15 +643,15 @@ function saveNotebook(hook) {
}
function deleteFocusedCell(hook) {
if (hook.state.focusedCellId) {
hook.pushEvent("delete_cell", { cell_id: hook.state.focusedCellId });
if (hook.state.focusedId && isCell(hook.state.focusedId)) {
hook.pushEvent("delete_cell", { cell_id: hook.state.focusedId });
}
}
function queueFocusedCellEvaluation(hook) {
if (hook.state.focusedCellId) {
if (hook.state.focusedId && isCell(hook.state.focusedId)) {
hook.pushEvent("queue_cell_evaluation", {
cell_id: hook.state.focusedCellId,
cell_id: hook.state.focusedId,
});
}
}
@ -631,17 +661,21 @@ function queueAllCellsEvaluation(hook) {
}
function queueFocusedSectionEvaluation(hook) {
if (hook.state.focusedSectionId) {
hook.pushEvent("queue_section_cells_evaluation", {
section_id: hook.state.focusedSectionId,
});
if (hook.state.focusedId) {
const sectionId = getSectionIdByFocusableId(hook.state.focusedId);
if (sectionId) {
hook.pushEvent("queue_section_cells_evaluation", {
section_id: sectionId,
});
}
}
}
function cancelFocusedCellEvaluation(hook) {
if (hook.state.focusedCellId) {
if (hook.state.focusedId && isCell(hook.state.focusedId)) {
hook.pushEvent("cancel_cell_evaluation", {
cell_id: hook.state.focusedCellId,
cell_id: hook.state.focusedId,
});
}
}
@ -655,7 +689,7 @@ function showShortcuts(hook) {
}
function enterInsertMode(hook) {
if (hook.state.focusedCellId) {
if (hook.state.focusedId) {
setInsertMode(hook, true);
}
}
@ -664,83 +698,88 @@ function escapeInsertMode(hook) {
setInsertMode(hook, false);
}
function moveCellFocus(hook, offset) {
const cellId = nearbyCellId(hook.state.focusedCellId, offset);
setFocusedCell(hook, cellId);
function moveFocus(hook, offset) {
const focusableId = nearbyFocusableId(hook.state.focusedId, offset);
setFocusedEl(hook, focusableId);
}
function moveFocusedCell(hook, offset) {
if (hook.state.focusedCellId) {
hook.pushEvent("move_cell", { cell_id: hook.state.focusedCellId, offset });
if (hook.state.focusedId && isCell(hook.state.focusedId)) {
hook.pushEvent("move_cell", { cell_id: hook.state.focusedId, offset });
}
}
function insertCellBelowFocused(hook, type) {
if (hook.state.focusedCellId) {
hook.pushEvent("insert_cell_below", {
cell_id: hook.state.focusedCellId,
type,
});
if (hook.state.focusedId) {
insertCellBelowFocusableId(hook, hook.state.focusedId, type);
} else {
// If no cell is focused, insert below the last cell
const cellIds = getCellIds();
if (cellIds.length > 0) {
const lastCellId = cellIds[cellIds.length - 1];
hook.pushEvent("insert_cell_below", { cell_id: lastCellId, type });
} else {
insertFirstCell(hook, type);
const focusableIds = getFocusableIds();
if (focusableIds.length > 0) {
insertCellBelowFocusableId(
hook,
focusableIds[focusableIds.length - 1],
type
);
}
}
}
function insertCellAboveFocused(hook, type) {
if (hook.state.focusedCellId) {
hook.pushEvent("insert_cell_above", {
cell_id: hook.state.focusedCellId,
type,
});
if (hook.state.focusedId) {
const prevFocusableId = nearbyFocusableId(hook.state.focusedId, -1);
insertCellBelowFocusableId(hook, prevFocusableId, type);
} else {
// If no cell is focused, insert above the first cell
const cellIds = getCellIds();
if (cellIds.length > 0) {
const lastCellId = cellIds[0];
hook.pushEvent("insert_cell_above", { cell_id: lastCellId, type });
} else {
insertFirstCell(hook, type);
const focusableIds = getFocusableIds();
if (focusableIds.length > 0) {
insertCellBelowFocusableId(hook, focusableIds[0], type);
}
}
}
function insertFirstCell(hook, type) {
const sectionIds = getSectionIds();
if (sectionIds.length > 0) {
hook.pushEvent("insert_cell", {
section_id: sectionIds[0],
index: 0,
type,
});
function insertCellBelowFocusableId(hook, focusableId, type) {
if (isCell(focusableId)) {
hook.pushEvent("insert_cell_below", { type, cell_id: focusableId });
} else if (isSection(focusableId)) {
hook.pushEvent("insert_cell_below", { type, section_id: focusableId });
} else if (isNotebook(focusableId)) {
const sectionIds = getSectionIds();
if (sectionIds.length > 0) {
hook.pushEvent("insert_cell_below", { type, section_id: sectionIds[0] });
}
}
}
function setFocusedCell(hook, cellId, scroll = true) {
hook.state.focusedCellId = cellId;
function setFocusedEl(
hook,
focusableId,
{ scroll = true, focusElement = true } = {}
) {
hook.state.focusedId = focusableId;
if (hook.state.focusedCellId) {
const cell = getCellById(hook.state.focusedCellId);
hook.state.focusedCellType = cell.getAttribute("data-type");
hook.state.focusedSectionId = getSectionIdByCellId(
hook.state.focusedCellId
);
// Focus the primary cell content, this is important for screen readers
const cellBody = cell.querySelector(`[data-element="cell-body"]`);
cellBody.focus({ preventScroll: true });
if (focusableId) {
const el = getFocusableEl(focusableId);
if (isCell(focusableId)) {
hook.state.focusedCellType = el.getAttribute("data-type");
}
if (focusElement) {
// Focus the primary content in the focusable element, this is important for screen readers
const contentEl =
el.querySelector(`[data-element="cell-body"]`) ||
el.querySelector(`[data-element="heading"]`) ||
el;
contentEl.focus({ preventScroll: true });
}
} else {
hook.state.focusedCellType = null;
hook.state.focusedSectionId = null;
}
globalPubSub.broadcast("cells", { type: "cell_focused", cellId, scroll });
globalPubSub.broadcast("navigation", {
type: "element_focused",
focusableId: focusableId,
scroll,
});
setInsertMode(hook, false);
}
@ -754,12 +793,12 @@ function setInsertMode(hook, insertModeEnabled) {
hook.el.removeAttribute("data-js-insert-mode");
sendLocationReport(hook, {
cellId: hook.state.focusedCellId,
focusableId: hook.state.focusedId,
selection: null,
});
}
globalPubSub.broadcast("cells", {
globalPubSub.broadcast("navigation", {
type: "insert_mode_changed",
enabled: insertModeEnabled,
});
@ -768,48 +807,41 @@ function setInsertMode(hook, insertModeEnabled) {
// Server event handlers
function handleCellInserted(hook, cellId) {
setFocusedCell(hook, cellId);
setFocusedEl(hook, cellId);
if (["markdown", "elixir"].includes(hook.state.focusedCellType)) {
setInsertMode(hook, true);
}
}
function handleCellDeleted(hook, cellId, siblingCellId) {
if (hook.state.focusedCellId === cellId) {
setFocusedCell(hook, siblingCellId);
if (hook.state.focusedId === cellId) {
setFocusedEl(hook, siblingCellId);
}
}
function handleCellRestored(hook, cellId) {
setFocusedCell(hook, cellId);
setFocusedEl(hook, cellId);
}
function handleCellMoved(hook, cellId) {
if (hook.state.focusedCellId === cellId) {
if (hook.state.focusedId === cellId) {
globalPubSub.broadcast("cells", { type: "cell_moved", cellId });
// The cell may have moved to another section, so update this information.
hook.state.focusedSectionId = getSectionIdByCellId(
hook.state.focusedCellId
);
}
}
function handleSectionInserted(hook, sectionId) {
if (hook.state.focusedSectionId) {
setFocusedCell(hook, null);
}
const section = getSectionById(sectionId);
const nameElement = section.querySelector(`[data-element="section-name"]`);
nameElement.focus({ preventScroll: true });
selectElementContent(nameElement);
smoothlyScrollToElement(nameElement);
const headlineEl = section.querySelector(`[data-element="section-headline"]`);
const { focusableId } = headlineEl.dataset;
setFocusedEl(hook, focusableId);
setInsertMode(hook, true);
selectElementContent(document.activeElement);
}
function handleSectionDeleted(hook, sectionId) {
if (hook.state.focusedSectionId === sectionId) {
setFocusedCell(hook, null);
// Clear focus if the element no longer exists
if (hook.state.focusedId && !getFocusableEl(hook.state.focusedId)) {
setFocusedEl(hook, null);
}
}
@ -819,8 +851,8 @@ function handleSectionMoved(hook, sectionId) {
}
function handleCellUpload(hook, cellId, url) {
if (hook.state.focusedCellId !== cellId) {
setFocusedCell(hook, cellId);
if (hook.state.focusedId !== cellId) {
setFocusedEl(hook, cellId);
}
if (!hook.state.insertMode) {
@ -840,7 +872,7 @@ function handleClientLeft(hook, clientPid) {
if (client) {
delete hook.state.clientsMap[clientPid];
broadcastLocationReport(client, { cellId: null, selection: null });
broadcastLocationReport(client, { focusableId: null, selection: null });
if (client.pid === hook.state.followedClientPid) {
hook.state.followedClientPid = null;
@ -864,29 +896,19 @@ function handleLocationReport(hook, clientPid, report) {
if (
client.pid === hook.state.followedClientPid &&
report.cellId !== hook.state.focusedCellId
report.focusableId !== hook.state.focusedId
) {
setFocusedCell(hook, report.cellId);
setFocusedEl(hook, report.focusableId);
}
}
}
function focusNotebookNameIfNew() {
const sections = getSections();
const nameElement = document.querySelector(`[data-element="notebook-name"]`);
if (sections.length === 0 && nameElement.innerText === "Untitled notebook") {
nameElement.focus();
selectElementContent(nameElement);
}
}
// Session event handlers
function handleSessionEvent(hook, event) {
if (event.type === "cursor_selection_changed") {
sendLocationReport(hook, {
cellId: event.cellId,
focusableId: event.focusableId,
selection: event.selection,
});
}
@ -896,7 +918,7 @@ function handleSessionEvent(hook, event) {
* Broadcast new location report coming from the server to all the cells.
*/
function broadcastLocationReport(client, report) {
globalPubSub.broadcast("cells", {
globalPubSub.broadcast("navigation", {
type: "location_report",
client,
report,
@ -912,7 +934,7 @@ function sendLocationReport(hook, report) {
// Only send reports if there are other people to send to
if (numberOfClients > 1) {
hook.pushEvent("location_report", {
cell_id: report.cellId,
focusable_id: report.focusableId,
selection: encodeSelection(report.selection),
});
}
@ -949,44 +971,51 @@ function decodeSelection(encoded) {
// Helpers
function nearbyCellId(cellId, offset) {
const cellIds = getCellIds();
function nearbyFocusableId(focusableId, offset) {
const focusableIds = getFocusableIds();
if (cellIds.length === 0) {
if (focusableIds.length === 0) {
return null;
}
const idx = cellIds.indexOf(cellId);
const idx = focusableIds.indexOf(focusableId);
if (idx === -1) {
return cellIds[0];
return focusableIds[0];
} else {
const siblingIdx = clamp(idx + offset, 0, cellIds.length - 1);
return cellIds[siblingIdx];
const siblingIdx = clamp(idx + offset, 0, focusableIds.length - 1);
return focusableIds[siblingIdx];
}
}
function getCellIds() {
const cells = getCells();
return cells.map((cell) => cell.getAttribute("data-cell-id"));
function isCell(focusableId) {
const el = getFocusableEl(focusableId);
return el.dataset.element === "cell";
}
function getCells() {
return Array.from(document.querySelectorAll(`[data-element="cell"]`));
function isSection(focusableId) {
const el = getFocusableEl(focusableId);
return el.dataset.element === "section-headline";
}
function getCellById(cellId) {
return document.querySelector(
`[data-element="cell"][data-cell-id="${cellId}"]`
);
function isNotebook(focusableId) {
const el = getFocusableEl(focusableId);
return el.dataset.element === "notebook-headline";
}
function getSectionIdByCellId(cellId) {
const cell = document.querySelector(
`[data-element="cell"][data-cell-id="${cellId}"]`
);
const section = cell.closest(`[data-element="section"]`);
return section.getAttribute("data-section-id");
function getFocusableEl(focusableId) {
return document.querySelector(`[data-focusable-id="${focusableId}"]`);
}
function getFocusableIds() {
const elements = Array.from(document.querySelectorAll(`[data-focusable-id]`));
return elements.map((el) => el.getAttribute("data-focusable-id"));
}
function getSectionIdByFocusableId(focusableId) {
const el = getFocusableEl(focusableId);
const section = el.closest(`[data-element="section"]`);
return section && section.getAttribute("data-section-id");
}
function getSectionIds() {

View file

@ -34,6 +34,8 @@ defmodule LivebookWeb.Helpers do
<div class={"relative max-h-full overflow-y-auto bg-white rounded-lg shadow-xl #{@class}"}
role="dialog"
aria-modal="true"
tabindex="0"
autofocus
phx-window-keydown={click_modal_close()}
phx-click-away={click_modal_close()}
phx-key="escape">
@ -315,7 +317,7 @@ defmodule LivebookWeb.Helpers do
<button>Open</button>
</:toggle>
<:content>
<button class"menu-item">Option 1</button>
<button class"menu-item" role="menuitem">Option 1</button>
</:content>
</.menu>
"""
@ -332,12 +334,14 @@ defmodule LivebookWeb.Helpers do
<div
phx-click={not @disabled && JS.toggle(to: "##{@id}-content")}
phx-click-away={JS.hide(to: "##{@id}-content")}
data-contextmenu-trigger-click={@secondary_click}>
data-contextmenu-trigger-click={@secondary_click}
phx-window-keydown={JS.hide(to: "##{@id}-content")}
phx-key="escape">
<%= render_slot(@toggle) %>
</div>
<div id={"#{@id}-content"} class={"hidden menu #{@position}"}>
<menu id={"#{@id}-content"} class={"hidden menu #{@position}"} role="menu">
<%= render_slot(@content) %>
</div>
</menu>
</div>
"""
end

View file

@ -84,19 +84,14 @@ defmodule LivebookWeb.FileSelectComponent do
autocomplete="off" />
</form>
</div>
<.menu id="path-selector-menu">
<:toggle>
<button class="icon-button" tabindex="-1">
<.remix_icon icon="add-line" class="text-xl" />
</button>
</:toggle>
<:content>
<button class="menu-item text-gray-500" phx-click={js_show_new_dir_section()}>
<.remix_icon icon="folder-add-fill" class="text-gray-400" />
<span class="font-medium">New directory</span>
</button>
</:content>
</.menu>
<span class="tooltip top" data-tooltip="New directory">
<button class="icon-button"
tabindex="-1"
aria-label="new directory"
phx-click={js_show_new_dir_section()}>
<.remix_icon icon="add-line" class="text-xl" />
</button>
</span>
<%= if @inner_block do %>
<div>
<%= render_slot(@inner_block) %>
@ -193,19 +188,22 @@ defmodule LivebookWeb.FileSelectComponent do
~H"""
<.menu id="file-system-menu" disabled={@file_system_select_disabled} position="left">
<:toggle>
<button type="button" class="button button-gray button-square-icon" disabled={@file_system_select_disabled}>
<button type="button" class="button button-gray button-square-icon"
aria-label="switch file system"
disabled={@file_system_select_disabled}>
<.file_system_icon file_system={@file.file_system} />
</button>
</:toggle>
<:content>
<%= for {file_system, index} <- @file_systems |> Enum.with_index() do %>
<%= if file_system == @file.file_system do %>
<button class="menu-item text-gray-900">
<button class="menu-item text-gray-900" role="menuitem">
<.file_system_icon file_system={file_system} />
<span class="font-medium"><%= file_system_label(file_system) %></span>
</button>
<% else %>
<button class="menu-item text-gray-500"
role="menuitem"
phx-target={@myself}
phx-click="set_file_system"
phx-value-index={index}>
@ -215,7 +213,8 @@ defmodule LivebookWeb.FileSelectComponent do
<% end %>
<% end %>
<%= live_patch to: Routes.settings_path(@socket, :page),
class: "menu-item text-gray-500 border-t border-gray-200" do %>
class: "menu-item text-gray-500 border-t border-gray-200",
role: "menuitem" do %>
<.remix_icon icon="settings-3-line" />
<span class="font-medium">Configure</span>
<% end %>
@ -287,6 +286,7 @@ defmodule LivebookWeb.FileSelectComponent do
<:content>
<%= if @file_info.editable do %>
<button class="menu-item text-gray-500"
role="menuitem"
phx-click="rename_file"
phx-target={@myself}
phx-value-path={@file_info.file.path}>
@ -294,6 +294,7 @@ defmodule LivebookWeb.FileSelectComponent do
<span class="font-medium">Rename</span>
</button>
<button class="menu-item text-red-600"
role="menuitem"
phx-click="delete_file"
phx-target={@myself}
phx-value-path={@file_info.file.path}>

View file

@ -38,6 +38,7 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
<:content>
<%= for order_by <- ["date", "title"] do %>
<button class={"menu-item #{if order_by == @order_by, do: "text-gray-900", else: "text-gray-500"}"}
role="menuitem"
phx-click={JS.push("set_order", value: %{order_by: order_by}, target: @myself)}>
<.remix_icon icon={order_by_icon(order_by)} />
<span class="font-medium"><%= order_by_label(order_by) %></span>
@ -85,25 +86,28 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
</div>
<.menu id={"session-#{session.id}-menu"}>
<:toggle>
<button class="icon-button">
<button class="icon-button" aria-label="open session menu">
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
</:toggle>
<:content>
<button class="menu-item text-gray-500"
role="menuitem"
phx-click="fork_session"
phx-value-id={session.id}>
<.remix_icon icon="git-branch-line" />
<span class="font-medium">Fork</span>
</button>
<a class="menu-item text-gray-500"
role="menuitem"
href={live_dashboard_process_path(@socket, session.pid)}
target="_blank">
<.remix_icon icon="dashboard-2-line" />
<span class="font-medium">See on Dashboard</span>
</a>
<%= live_patch to: Routes.home_path(@socket, :close_session, session.id),
class: "menu-item text-red-600" do %>
class: "menu-item text-red-600",
role: "menuitem" do %>
<.remix_icon icon="close-circle-line" />
<span class="font-medium">Close</span>
<% end %>

View file

@ -73,7 +73,7 @@ defmodule LivebookWeb.SessionLive do
def render(assigns) do
~H"""
<div class="flex flex-grow h-full"
id="session"
id={"session-#{@session.id}"}
data-element="session"
phx-hook="Session"
data-global-status={elem(@data_view.global_status, 0)}
@ -121,46 +121,53 @@ defmodule LivebookWeb.SessionLive do
</div>
<div class="flex-grow overflow-y-auto scroll-smooth" data-element="notebook">
<div class="w-full max-w-screen-lg px-16 mx-auto py-7">
<div class="flex items-center pb-4 mb-6 space-x-4 border-b border-gray-200">
<h1 class="flex-grow p-1 -ml-1 text-3xl font-semibold text-gray-800 border border-transparent rounded-lg hover:border-blue-200 focus:border-blue-300"
aria-description="notebook title"
id="notebook-name"
data-element="notebook-name"
contenteditable
spellcheck="false"
phx-blur="set_notebook_name"
phx-hook="ContentEditable"
data-update-attribute="phx-value-name"><%= @data_view.notebook_name %></h1>
<div class="flex items-center pb-4 mb-6 space-x-4 border-b border-gray-200"
data-element="notebook-headline"
data-focusable-id="notebook"
id="notebook"
phx-hook="Headline"
data-on-value-change="set_notebook_name"
data-metadata="notebook">
<h1 class="flex-grow p-1 -ml-1 text-3xl font-semibold text-gray-800 border border-transparent rounded-lg whitespace-pre-wrap"
tabindex="0"
id="notebook-heading"
data-element="heading"
spellcheck="false"><%= @data_view.notebook_name %></h1>
<.menu id="session-menu">
<:toggle>
<button class="icon-button">
<button class="icon-button" aria-label="open notebook menu">
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
</:toggle>
<:content>
<%= live_patch to: Routes.session_path(@socket, :export, @session.id, "livemd"),
class: "menu-item text-gray-500" do %>
class: "menu-item text-gray-500",
role: "menuitem" do %>
<.remix_icon icon="download-2-line" />
<span class="font-medium">Export</span>
<% end %>
<button class="menu-item text-gray-500"
role="menuitem"
phx-click="erase_outputs">
<.remix_icon icon="eraser-fill" />
<span class="font-medium">Erase outputs</span>
</button>
<button class="menu-item text-gray-500"
role="menuitem"
phx-click="fork_session">
<.remix_icon icon="git-branch-line" />
<span class="font-medium">Fork</span>
</button>
<a class="menu-item text-gray-500"
role="menuitem"
href={live_dashboard_process_path(@socket, @session.pid)}
target="_blank">
<.remix_icon icon="dashboard-2-line" />
<span class="font-medium">See on Dashboard</span>
</a>
<%= live_patch to: Routes.home_path(@socket, :close_session, @session.id),
class: "menu-item text-red-600" do %>
class: "menu-item text-red-600",
role: "menuitem" do %>
<.remix_icon icon="close-circle-line" />
<span class="font-medium">Close</span>
<% end %>
@ -560,9 +567,15 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("insert_section_into", %{"section_id" => section_id, "index" => index}, socket) do
index = ensure_integer(index) |> max(0)
Session.insert_section_into(socket.assigns.session.pid, section_id, index)
def handle_event("insert_section_below", params, socket) do
with {:ok, section, index} <-
section_with_next_index(
socket.private.data.notebook,
params["section_id"],
params["cell_id"]
) do
Session.insert_section_into(socket.assigns.session.pid, section.id, index)
end
{:noreply, socket}
end
@ -583,28 +596,17 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event(
"insert_cell",
%{"section_id" => section_id, "index" => index, "type" => type},
socket
) do
index = ensure_integer(index) |> max(0)
def handle_event("insert_cell_below", %{"type" => type} = params, socket) do
type = String.to_atom(type)
Session.insert_cell(socket.assigns.session.pid, section_id, index, type)
{:noreply, socket}
end
def handle_event("insert_cell_below", %{"cell_id" => cell_id, "type" => type}, socket) do
type = String.to_atom(type)
insert_cell_next_to(socket, cell_id, type, idx_offset: 1)
{:noreply, socket}
end
def handle_event("insert_cell_above", %{"cell_id" => cell_id, "type" => type}, socket) do
type = String.to_atom(type)
insert_cell_next_to(socket, cell_id, type, idx_offset: 0)
with {:ok, section, index} <-
section_with_next_index(
socket.private.data.notebook,
params["section_id"],
params["cell_id"]
) do
Session.insert_cell(socket.assigns.session.pid, section.id, index, type)
end
{:noreply, socket}
end
@ -615,14 +617,14 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("set_notebook_name", %{"name" => name}, socket) do
def handle_event("set_notebook_name", %{"value" => name}, socket) do
name = normalize_name(name)
Session.set_notebook_name(socket.assigns.session.pid, name)
{:noreply, socket}
end
def handle_event("set_section_name", %{"section_id" => section_id, "name" => name}, socket) do
def handle_event("set_section_name", %{"metadata" => section_id, "value" => name}, socket) do
name = normalize_name(name)
Session.set_section_name(socket.assigns.session.pid, section_id, name)
@ -1191,10 +1193,19 @@ defmodule LivebookWeb.SessionLive do
String.upcase(head) <> tail
end
defp insert_cell_next_to(socket, cell_id, type, idx_offset: idx_offset) do
{:ok, cell, section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
index = Enum.find_index(section.cells, &(&1 == cell))
Session.insert_cell(socket.assigns.session.pid, section.id, index + idx_offset, type)
defp section_with_next_index(notebook, section_id, cell_id)
defp section_with_next_index(notebook, section_id, nil) do
with {:ok, section} <- Notebook.fetch_section(notebook, section_id) do
{:ok, section, 0}
end
end
defp section_with_next_index(notebook, _section_id, cell_id) do
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(notebook, cell_id) do
index = Enum.find_index(section.cells, &(&1 == cell))
{:ok, section, index + 1}
end
end
defp ensure_integer(n) when is_integer(n), do: n

View file

@ -9,6 +9,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
id={"cell-#{@cell_view.id}"}
phx-hook="Cell"
data-cell-id={@cell_view.id}
data-focusable-id={@cell_view.id}
data-type={@cell_view.type}
data-session-path={Routes.session_path(@socket, :page, @session_id)}>
<%= render_cell(assigns) %>
@ -19,7 +20,10 @@ defmodule LivebookWeb.SessionLive.CellComponent do
defp render_cell(%{cell_view: %{type: :markdown}} = assigns) do
~H"""
<div class="mb-1 flex items-center justify-end">
<div class="relative z-20 flex items-center justify-end space-x-2" data-element="actions">
<div class="relative z-20 flex items-center justify-end space-x-2"
role="toolbar"
aria-label="cell actions"
data-element="actions">
<span class="tooltip top" data-tooltip="Edit content" data-element="enable-insert-mode-button">
<button class="icon-button" aria-label="edit content">
<.remix_icon icon="pencil-line" class="text-xl" />
@ -28,7 +32,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<span class="tooltip top" data-tooltip="Insert image" data-element="insert-image-button">
<%= live_patch to: Routes.session_path(@socket, :cell_upload, @session_id, @cell_view.id),
class: "icon-button",
aria_label: "insert image" do %>
aria_label: "insert image",
role: "button" do %>
<.remix_icon icon="image-add-line" class="text-xl" />
<% end %>
</span>
@ -87,9 +92,13 @@ defmodule LivebookWeb.SessionLive.CellComponent do
</button>
<% end %>
</div>
<div class="relative z-20 flex items-center justify-end space-x-2" data-element="actions">
<div class="relative z-20 flex items-center justify-end space-x-2"
role="toolbar"
aria-label="cell actions"
data-element="actions">
<span class="tooltip top" data-tooltip="Amplify output" data-element="amplify-outputs-button">
<button class="icon-button" aria-label="amplify outputs">
<button class="icon-button"
aria-label="amplify outputs">
<.remix_icon icon="zoom-in-line" class="text-xl" />
</button>
</span>
@ -116,7 +125,10 @@ defmodule LivebookWeb.SessionLive.CellComponent do
defp render_cell(%{cell_view: %{type: :input}} = assigns) do
~H"""
<div class="mb-1 flex items-center justify-end">
<div class="relative z-20 flex items-center justify-end space-x-2" data-element="actions">
<div class="relative z-20 flex items-center justify-end space-x-2"
role="toolbar"
aria-label="cell actions"
data-element="actions">
<.cell_settings_button cell_id={@cell_view.id} socket={@socket} session_id={@session_id} />
<.cell_link_button cell_id={@cell_view.id} />
<.move_cell_up_button cell_id={@cell_view.id} />
@ -246,8 +258,9 @@ defmodule LivebookWeb.SessionLive.CellComponent do
defp cell_body(assigns) do
~H"""
<!-- By setting tabindex="-1" we can programmatically focus this element -->
<div class="flex relative" data-element="cell-body" tabindex="-1">
<!-- By setting tabindex we can programmatically focus this element,
also we actually want to make this element tab-focusable -->
<div class="flex relative" data-element="cell-body" tabindex="0">
<div class="w-1 h-full rounded-lg absolute top-0 -left-3" data-element="cell-focus-indicator">
</div>
<div class="w-full">
@ -260,7 +273,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
defp cell_link_button(assigns) do
~H"""
<span class="tooltip top" data-tooltip="Link">
<a href={"#cell-#{@cell_id}"} class="icon-button" aria-label="link to cell">
<a href={"#cell-#{@cell_id}"} class="icon-button"
aria-label="link to cell">
<.remix_icon icon="link" class="text-xl" />
</a>
</span>
@ -272,7 +286,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<span class="tooltip top" data-tooltip="Cell settings">
<%= live_patch to: Routes.session_path(@socket, :cell_settings, @session_id, @cell_id),
class: "icon-button",
aria_label: "cell settings" do %>
aria_label: "cell settings",
role: "button" do %>
<.remix_icon icon="settings-3-line" class="text-xl" />
<% end %>
</span>

View file

@ -3,30 +3,33 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
def render(assigns) do
~H"""
<div class="relative top-0.5 m-0 flex justify-center" data-element="insert-buttons">
<div class="relative top-0.5 m-0 flex justify-center"
role="toolbar"
aria-label="insert new"
data-element="insert-buttons">
<div class={"w-full absolute z-10 #{if(@persistent, do: "opacity-100", else: "opacity-0")} hover:opacity-100 focus-within:opacity-100 flex space-x-2 justify-center items-center"}>
<button class="button button-small"
phx-click="insert_cell"
phx-click="insert_cell_below"
phx-value-type="markdown"
phx-value-section_id={@section_id}
phx-value-index={@insert_cell_index}
phx-value-cell_id={@cell_id}
>+ Markdown</button>
<button class="button button-small"
phx-click="insert_cell"
phx-click="insert_cell_below"
phx-value-type="elixir"
phx-value-section_id={@section_id}
phx-value-index={@insert_cell_index}
phx-value-cell_id={@cell_id}
>+ Elixir</button>
<button class="button button-small"
phx-click="insert_cell"
phx-click="insert_cell_below"
phx-value-type="input"
phx-value-section_id={@section_id}
phx-value-index={@insert_cell_index}
phx-value-cell_id={@cell_id}
>+ Input</button>
<button class="button button-small"
phx-click="insert_section_into"
phx-click="insert_section_below"
phx-value-section_id={@section_id}
phx-value-index={@insert_cell_index}
phx-value-cell_id={@cell_id}
>+ Section</button>
</div>
</div>

View file

@ -3,22 +3,22 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
def render(assigns) do
~H"""
<div data-element="section" data-section-id={@section_view.id}>
<div class="flex space-x-4 items-center" data-element="section-headline">
<h2 class="flex-grow text-gray-800 font-semibold text-2xl px-1 -ml-1 rounded-lg border
border-transparent hover:border-blue-200 focus:border-blue-300"
aria-description="section title"
data-element="section-name"
<section data-element="section" data-section-id={@section_view.id}>
<div class="flex space-x-4 items-center"
data-element="section-headline"
id={@section_view.id}
data-focusable-id={@section_view.id}
phx-hook="Headline"
data-on-value-change="set_section_name"
data-metadata={@section_view.id}>
<h2 class="flex-grow text-gray-800 font-semibold text-2xl px-1 -ml-1 rounded-lg border border-transparent whitespace-pre-wrap cursor-text"
tabindex="0"
id={@section_view.html_id}
contenteditable
spellcheck="false"
phx-blur="set_section_name"
phx-value-section_id={@section_view.id}
phx-hook="ContentEditable"
data-update-attribute="phx-value-name"><%= @section_view.name %></h2>
<%# ^ Note it's important there's no space between <h2> and </h2>
because we want the content to exactly match section name. %>
<div class="flex space-x-2 items-center" data-element="section-actions">
data-element="heading"
spellcheck="false"><%= @section_view.name %></h2>
<div class="flex space-x-2 items-center" data-element="section-actions"
role="toolbar"
aria-label="section actions">
<span class="tooltip top" data-tooltip="Link">
<a href={"##{@section_view.html_id}"} class="icon-button" aria-label="link to section">
<.remix_icon icon="link" class="text-xl" />
@ -28,8 +28,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
<.menu id={"section-#{@section_view.id}-branch-menu"}>
<:toggle>
<span class="tooltip top" data-tooltip="Branch out from">
<button class="icon-button"
aria-label="branch out from other section">
<button class="icon-button" aria-label="branch out from other section">
<.remix_icon icon="git-branch-line" class="text-xl flip-horizontally" />
</button>
</span>
@ -74,10 +73,14 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
<.remix_icon icon="arrow-down-s-line" class="text-xl" />
</button>
</span>
<span class="tooltip top" data-tooltip={if @section_view.has_children?, do: "Cannot delete this section because\nother sections branch from it", else: "Delete"}>
<span
{if @section_view.has_children?,
do: [class: "tooltip left", data_tooltip: "Cannot delete this section because\nother sections branch from it"],
else: [class: "tooltip top", data_tooltip: "Delete"]}>
<%= live_patch to: Routes.session_path(@socket, :delete_section, @session_id, @section_view.id),
class: "icon-button #{if @section_view.has_children?, do: "disabled"}",
aria_label: "delete section" do %>
aria_label: "delete section",
role: "button" do %>
<.remix_icon icon="delete-bin-6-line" class="text-xl" />
<% end %>
</span>
@ -93,28 +96,26 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
<% end %>
<div class="container">
<div class="flex flex-col space-y-1">
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
id={"#{@section_view.id}:first"}
persistent={@section_view.cell_views == []}
section_id={@section_view.id}
cell_id={nil} />
<%= for {cell_view, index} <- Enum.with_index(@section_view.cell_views) do %>
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
id={"#{@section_view.id}:#{index}"}
persistent={false}
section_id={@section_view.id}
insert_cell_index={index} />
<.live_component module={LivebookWeb.SessionLive.CellComponent}
id={cell_view.id}
session_id={@session_id}
runtime={@runtime}
cell_view={cell_view} />
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
id={"#{@section_view.id}:#{index}"}
persistent={false}
section_id={@section_view.id}
cell_id={cell_view.id} />
<% end %>
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
id={"#{@section_view.id}:last"}
persistent={@section_view.cell_views == []}
section_id={@section_view.id}
insert_cell_index={length(@section_view.cell_views)} />
</div>
</div>
</div>
</section>
"""
end
end

View file

@ -14,9 +14,9 @@ defmodule LivebookWeb.SidebarHelpers do
"""
def sidebar(assigns) do
~H"""
<div class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
<nav class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
<%= render_slot(@inner_block) %>
</div>
</nav>
"""
end

View file

@ -101,7 +101,7 @@ defmodule LivebookWeb.SessionLiveTest do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> element(~s{[data-element="session"]})
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
assert %{cell_infos: %{^cell_id => %{evaluation_status: :evaluating}}} =
@ -115,11 +115,11 @@ defmodule LivebookWeb.SessionLiveTest do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> element(~s{[data-element="session"]})
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
view
|> element("#session")
|> element(~s{[data-element="session"]})
|> render_hook("cancel_cell_evaluation", %{"cell_id" => cell_id})
assert %{cell_infos: %{^cell_id => %{evaluation_status: :ready}}} =
@ -133,22 +133,22 @@ defmodule LivebookWeb.SessionLiveTest do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> element(~s{[data-element="session"]})
|> render_hook("insert_cell_below", %{"cell_id" => cell_id, "type" => "markdown"})
assert %{notebook: %{sections: [%{cells: [_first_cell, %Cell.Markdown{}]}]}} =
Session.get_data(session.pid)
end
test "inserting a cell above the given cell", %{conn: conn, session: session} do
test "inserting a cell at section start", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :elixir)
_cell_id = insert_text_cell(session.pid, section_id, :elixir)
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> render_hook("insert_cell_above", %{"cell_id" => cell_id, "type" => "markdown"})
|> element(~s{[data-element="session"]})
|> render_hook("insert_cell_below", %{"section_id" => section_id, "type" => "markdown"})
assert %{notebook: %{sections: [%{cells: [%Cell.Markdown{}, _first_cell]}]}} =
Session.get_data(session.pid)
@ -161,7 +161,7 @@ defmodule LivebookWeb.SessionLiveTest do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> element(~s{[data-element="session"]})
|> render_hook("delete_cell", %{"cell_id" => cell_id})
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session.pid)
@ -285,7 +285,7 @@ defmodule LivebookWeb.SessionLiveTest do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> element(~s{[data-element="session"]})
|> render_hook("intellisense_request", %{
"cell_id" => cell_id,
"type" => "completion",
@ -306,7 +306,7 @@ defmodule LivebookWeb.SessionLiveTest do
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element("#session")
|> element(~s{[data-element="session"]})
|> render_hook("intellisense_request", %{
"cell_id" => cell_id,
"type" => "completion",