From 5c828cc94c545f58d3cdee9aa215341fedad71ac Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 14:24:03 +0200
Subject: [PATCH 1/9] feat: add task list schema and core components
- Add task list schema with proper parseDOM and toDOM mappings
- Add task list item schema with checked attribute support
- Add task list commands for creation and toggling
- Add task list plugin with Enter key handling
- Add task list node view with interactive checkboxes
- Add task list styling with bullet suppression
---
.../plugins/task-list/task-list-commands.ts | 72 +++++++++++++++++++
.../plugins/task-list/task-list-node-view.ts | 70 ++++++++++++++++++
.../plugins/task-list/task-list-plugin.ts | 46 ++++++++++++
.../plugins/task-list/task-list-schema.ts | 69 ++++++++++++++++++
.../plugins/task-list/task-list.scss | 49 +++++++++++++
5 files changed, 306 insertions(+)
create mode 100644 src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-commands.ts
create mode 100644 src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-node-view.ts
create mode 100644 src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-plugin.ts
create mode 100644 src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-schema.ts
create mode 100644 src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list.scss
diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-commands.ts b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-commands.ts
new file mode 100644
index 0000000000..39eff2c8c9
--- /dev/null
+++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-commands.ts
@@ -0,0 +1,72 @@
+import { Command } from 'prosemirror-state';
+import {
+ wrapInList,
+ liftListItem,
+ sinkListItem,
+} from 'prosemirror-schema-list';
+import { Schema } from 'prosemirror-model';
+import { CommandWithActive } from '../../menu/menu-commands';
+
+export const createTaskList = (schema: Schema): CommandWithActive => {
+ const taskListType = schema.nodes.task_list;
+ const taskListItemType = schema.nodes.task_list_item;
+
+ if (!taskListType || !taskListItemType) {
+ return () => false;
+ }
+
+ const command: Command = wrapInList(taskListType, { checked: false });
+
+ // Add active state detection
+ const commandWithActive = command as CommandWithActive;
+ commandWithActive.active = (state) => {
+ const { $from } = state.selection;
+ for (let d = $from.depth; d >= 0; d--) {
+ const node = $from.node(d);
+ if (node && node.type === taskListType) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ return commandWithActive;
+};
+
+export const toggleTaskListItem = (schema: Schema): Command => {
+ const taskListItemType = schema.nodes.task_list_item;
+
+ return (state, dispatch) => {
+ const { from } = state.selection;
+ const $from = state.doc.resolve(from);
+
+ // Find the task list item
+ for (let d = $from.depth; d >= 0; d--) {
+ const node = $from.node(d);
+ if (node && node.type === taskListItemType) {
+ const pos = $from.before(d);
+ const checked = !node.attrs.checked;
+
+ if (dispatch) {
+ const tr = state.tr.setNodeMarkup(pos, null, {
+ ...node.attrs,
+ checked,
+ });
+ dispatch(tr);
+ }
+ return true;
+ }
+ }
+ return false;
+ };
+};
+
+export const liftTaskListItem = (schema: Schema): Command => {
+ const taskListItemType = schema.nodes.task_list_item;
+ return liftListItem(taskListItemType);
+};
+
+export const sinkTaskListItem = (schema: Schema): Command => {
+ const taskListItemType = schema.nodes.task_list_item;
+ return sinkListItem(taskListItemType);
+};
diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-node-view.ts b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-node-view.ts
new file mode 100644
index 0000000000..382a875643
--- /dev/null
+++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-node-view.ts
@@ -0,0 +1,70 @@
+import { NodeView } from 'prosemirror-view';
+import { Node as ProseMirrorNode } from 'prosemirror-model';
+
+export class TaskListItemView implements NodeView {
+ dom: HTMLElement;
+ contentDOM: HTMLElement;
+ checkbox: HTMLInputElement;
+ private getPos: () => number;
+ private view: any;
+
+ constructor(node: ProseMirrorNode, view: any, getPos: () => number) {
+ this.getPos = getPos;
+ this.view = view;
+
+ // Create the list item element
+ this.dom = document.createElement('li');
+ this.dom.className = 'task-list-item';
+
+ // Create the checkbox
+ this.checkbox = document.createElement('input');
+ this.checkbox.type = 'checkbox';
+ this.checkbox.checked = node.attrs.checked;
+
+ // Create content container
+ this.contentDOM = document.createElement('div');
+ this.contentDOM.className = 'task-list-item-content';
+
+ // Assemble the DOM
+ this.dom.append(this.checkbox);
+ this.dom.append(this.contentDOM);
+
+ // Add click handler for checkbox
+ this.checkbox.addEventListener('click', this.handleCheckboxClick);
+ }
+
+ private handleCheckboxClick = (event: Event) => {
+ // Don't prevent default - let the checkbox toggle naturally
+ const newChecked = (event.target as HTMLInputElement).checked;
+ const pos = this.getPos();
+ const tr = this.view.state.tr.setNodeMarkup(pos, null, {
+ checked: newChecked,
+ });
+ this.view.dispatch(tr);
+ };
+
+ update(node: ProseMirrorNode): boolean {
+ if (node.type.name !== 'task_list_item') {
+ return false;
+ }
+ this.checkbox.checked = node.attrs.checked;
+ return true;
+ }
+
+ stopEvent(event: Event): boolean {
+ // Allow checkbox clicks to be handled by our custom handler
+ return event.target === this.checkbox;
+ }
+
+ destroy(): void {
+ this.checkbox.removeEventListener('click', this.handleCheckboxClick);
+ }
+}
+
+export const createTaskListItemNodeView = (
+ node: ProseMirrorNode,
+ view: any,
+ getPos: () => number
+) => {
+ return new TaskListItemView(node, view, getPos);
+};
diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-plugin.ts
new file mode 100644
index 0000000000..616741360c
--- /dev/null
+++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-plugin.ts
@@ -0,0 +1,46 @@
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { keymap } from 'prosemirror-keymap';
+import { Schema } from 'prosemirror-model';
+import { splitListItem } from 'prosemirror-schema-list';
+
+const taskListKey = new PluginKey('taskList');
+
+export const createTaskListPlugin = (schema: Schema): Plugin => {
+ const taskListItemType = schema.nodes.task_list_item;
+ const listItemType = schema.nodes.list_item; // Also check regular list items
+
+ console.log('Task list plugin initialized');
+ console.log('taskListItemType:', taskListItemType);
+ console.log('listItemType:', listItemType);
+
+ if (!taskListItemType) {
+ console.log('No task_list_item type found in schema');
+ return new Plugin({
+ key: taskListKey,
+ });
+ }
+
+ return keymap({
+ Enter: (state, dispatch) => {
+ console.log('Enter key pressed in task list plugin');
+
+ // Debug: check what type of node we're in
+ const { selection } = state;
+ const { $from } = selection;
+
+ for (let d = $from.depth; d >= 0; d--) {
+ const node = $from.node(d);
+ console.log(`Depth ${d}:`, node.type.name, node.attrs);
+
+ if (node.type === taskListItemType) {
+ console.log('Found task_list_item, using splitListItem');
+ return splitListItem(taskListItemType)(state, dispatch);
+ }
+ }
+
+ console.log('Not in a task_list_item, allowing other handlers');
+ return false;
+ },
+ // Removed Mod-[ and Mod-] - now handled by list indentation plugin
+ });
+};
diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-schema.ts b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-schema.ts
new file mode 100644
index 0000000000..f8fea6a49b
--- /dev/null
+++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list-schema.ts
@@ -0,0 +1,69 @@
+import { NodeSpec } from 'prosemirror-model';
+
+export const taskListNodeSpec: NodeSpec = {
+ content: 'task_list_item+',
+ group: 'block',
+ parseDOM: [
+ {
+ tag: 'ul.task-list',
+ getAttrs: (dom) => {
+ if (dom instanceof HTMLElement) {
+ return {};
+ }
+ return false;
+ },
+ },
+ {
+ tag: 'ul.contains-task-list',
+ getAttrs: (dom) => {
+ if (dom instanceof HTMLElement) {
+ return {};
+ }
+ return false;
+ },
+ },
+ ],
+ toDOM: () => ['ul', { class: 'task-list' }, 0],
+};
+
+export const taskListItemNodeSpec: NodeSpec = {
+ content: 'paragraph',
+ attrs: {
+ checked: { default: false },
+ },
+ parseDOM: [
+ {
+ tag: 'li.task-list-item',
+ getAttrs: (dom) => {
+ if (dom instanceof HTMLElement) {
+ const checkbox = dom.querySelector(
+ 'input[type="checkbox"]'
+ );
+ return {
+ checked: checkbox
+ ? (checkbox as HTMLInputElement).checked
+ : false,
+ };
+ }
+ return false;
+ },
+ },
+ ],
+ toDOM: (node) => {
+ const { checked } = node.attrs;
+ return [
+ 'li',
+ { class: 'task-list-item' },
+ [
+ 'input',
+ {
+ type: 'checkbox',
+ checked: checked ? 'checked' : null,
+ disabled: 'disabled', // Make checkbox non-interactive in HTML output
+ },
+ ],
+ ['div', { class: 'task-list-item-content' }, 0],
+ ];
+ },
+ defining: true,
+};
diff --git a/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list.scss b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list.scss
new file mode 100644
index 0000000000..623a6ee120
--- /dev/null
+++ b/src/components/text-editor/prosemirror-adapter/plugins/task-list/task-list.scss
@@ -0,0 +1,49 @@
+/* Task list styles with native checkbox */
+.task-list {
+ list-style: none;
+ padding-left: 0;
+ margin-left: 0;
+}
+
+.task-list-item {
+ display: flex;
+ align-items: flex-start;
+ margin: 0.25rem 0;
+ position: relative;
+ list-style: none;
+ gap: 0.5rem; // Gap between checkbox and content
+}
+
+/* Make sure no bullets appear */
+.task-list-item::before {
+ display: none !important;
+}
+
+.task-list-item::marker {
+ display: none;
+}
+
+/* Native checkbox styling */
+.task-list-item input[type='checkbox'] {
+ flex-shrink: 0;
+ margin-top: 0.125rem; // Slight offset to align with text baseline
+ cursor: pointer;
+}
+
+.task-list-item-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.task-list-item-content p {
+ margin: 0;
+}
+
+/* Completed task styling */
+.task-list-item:has(input[type='checkbox']:checked) .task-list-item-content {
+ opacity: 0.6;
+ text-decoration: line-through;
+}
+.task-list-item input[type='checkbox']:checked + .task-list-item-content p {
+ text-decoration: line-through;
+}
From c53bd7145f5fc0edcd5a37170d5316afeffee2f6 Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 14:24:28 +0200
Subject: [PATCH 2/9] feat: add task list toolbar integration
- Add task list menu command with active state detection
- Add task list menu item with checkbox icon to toolbar
- Add task list to EditorMenuTypes for type safety
- Add translations for task list button in EN, NO, and SV
---
.../text-editor/prosemirror-adapter/menu/menu-commands.ts | 1 +
.../text-editor/prosemirror-adapter/menu/menu-items.ts | 8 ++++++++
.../text-editor/prosemirror-adapter/menu/types.ts | 1 +
src/translations/en.ts | 1 +
src/translations/no.ts | 1 +
src/translations/sv.ts | 1 +
6 files changed, 13 insertions(+)
diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts
index 8c79a826c2..077e40292f 100644
--- a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts
+++ b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts
@@ -329,6 +329,7 @@ const commandMapping: CommandMapping = {
createListCommand(schema, EditorMenuTypes.OrderedList),
bullet_list: (schema) =>
createListCommand(schema, EditorMenuTypes.BulletList),
+ task_list: (schema) => createListCommand(schema, EditorMenuTypes.TaskList),
};
export class MenuCommandFactory {
diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts
index 40377e5b3e..a6c8254a92 100644
--- a/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts
+++ b/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts
@@ -66,6 +66,13 @@ const textEditorMenuItems: Array<
iconOnly: true,
selected: false,
},
+ {
+ value: EditorMenuTypes.TaskList,
+ text: 'Task list',
+ icon: 'checklist',
+ iconOnly: true,
+ selected: false,
+ },
{ separator: true },
{
value: EditorMenuTypes.HeaderLevel1,
@@ -129,6 +136,7 @@ export const menuTranslationIDs = {
bullet_list: 'editor-menu.bulleted-list',
ordered_list: 'editor-menu.numbered-list',
+ task_list: 'editor-menu.task-list',
code_block: 'editor-menu.code-block',
blockquote: 'editor-menu.blockquote',
diff --git a/src/components/text-editor/prosemirror-adapter/menu/types.ts b/src/components/text-editor/prosemirror-adapter/menu/types.ts
index 8de1093fd8..e5cd0c99ef 100644
--- a/src/components/text-editor/prosemirror-adapter/menu/types.ts
+++ b/src/components/text-editor/prosemirror-adapter/menu/types.ts
@@ -15,6 +15,7 @@ export const EditorMenuTypes = {
Link: 'link',
OrderedList: 'ordered_list',
BulletList: 'bullet_list',
+ TaskList: 'task_list',
Strikethrough: 'strikethrough',
Code: 'code',
CodeBlock: 'code_block',
diff --git a/src/translations/en.ts b/src/translations/en.ts
index 482361b034..f5601e904b 100644
--- a/src/translations/en.ts
+++ b/src/translations/en.ts
@@ -39,6 +39,7 @@ export default {
'editor-menu.h3': 'Heading 3',
'editor-menu.bulleted-list': 'Bulleted list',
'editor-menu.numbered-list': 'Numbered list',
+ 'editor-menu.task-list': 'Task list',
'editor-menu.blockquote': 'Blockquote',
'editor-menu.link': 'Link',
'editor-link-menu.text': 'Text',
diff --git a/src/translations/no.ts b/src/translations/no.ts
index 41db144b7a..e19fbf5236 100644
--- a/src/translations/no.ts
+++ b/src/translations/no.ts
@@ -38,6 +38,7 @@ export default {
'editor-menu.h3': 'Overskrifts 3',
'editor-menu.bulleted-list': 'Punktliste',
'editor-menu.numbered-list': 'Nummerert liste',
+ 'editor-menu.task-list': 'Oppgaveliste',
'editor-menu.blockquote': 'Blokksitat',
'editor-menu.link': 'Legg til lenke',
'editor-link-menu.text': 'Tekst',
diff --git a/src/translations/sv.ts b/src/translations/sv.ts
index bc3dc15fd8..618b0bd951 100644
--- a/src/translations/sv.ts
+++ b/src/translations/sv.ts
@@ -39,6 +39,7 @@ export default {
'editor-menu.h3': 'Rubrik 3',
'editor-menu.bulleted-list': 'Punktlista',
'editor-menu.numbered-list': 'Numrerad lista',
+ 'editor-menu.task-list': 'Uppgiftslista',
'editor-menu.blockquote': 'Blockcitat',
'editor-menu.link': 'Lägg till länk',
'editor-link-menu.text': 'Text',
From 9f0f9cb3b095efa398a6aa261e1b66f907a9d8c9 Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 14:24:39 +0200
Subject: [PATCH 3/9] feat: integrate task lists into ProseMirror adapter
- Add task list schema to ProseMirror document schema
- Register task list item node view for interactive checkboxes
- Add task list plugin to editor plugin chain
- Import task list styles into main adapter stylesheet
---
.../prosemirror-adapter.scss | 1 +
.../prosemirror-adapter.tsx | 18 ++++++++++++++++++
2 files changed, 19 insertions(+)
diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss
index 8694219e73..4c6572ade7 100644
--- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss
+++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss
@@ -9,6 +9,7 @@
@forward '../../markdown/partial-styles/kbd';
@forward '../../markdown/partial-styles/img';
@forward 'plugins/image/view.scss';
+@forward 'plugins/task-list/task-list.scss';
:host(limel-prosemirror-adapter) {
display: flex;
diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx
index fdaa341604..487c085a03 100644
--- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx
+++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx
@@ -43,6 +43,13 @@ import { createActionBarInteractionPlugin } from './plugins/menu-action-interact
import { CustomElementDefinition } from '../../../global/shared-types/custom-element.types';
import { createNodeSpec } from '../utils/plugin-factory';
import { createTriggerPlugin } from './plugins/trigger/factory';
+import {
+ taskListNodeSpec,
+ taskListItemNodeSpec,
+} from './plugins/task-list/task-list-schema';
+import { createTaskListPlugin } from './plugins/task-list/task-list-plugin';
+import { createTaskListItemNodeView } from './plugins/task-list/task-list-node-view';
+import { createListIndentationPlugin } from './plugins/list-indentation-plugin';
import {
TriggerCharacter,
ImageInserter,
@@ -359,6 +366,9 @@ export class ProsemirrorAdapter {
{
state: this.createEditorState(initialDoc),
dispatchTransaction: this.handleTransaction,
+ nodeViews: {
+ task_list_item: createTaskListItemNodeView,
+ },
}
);
@@ -381,6 +391,12 @@ export class ProsemirrorAdapter {
}
nodes = addListNodes(nodes, 'paragraph block*', 'block');
+ // Add task list nodes
+ nodes = nodes.append({
+ task_list: taskListNodeSpec,
+ task_list_item: taskListItemNodeSpec,
+ });
+
if (this.contentType === 'html') {
nodes = nodes.append(getTableNodes());
}
@@ -416,6 +432,8 @@ export class ProsemirrorAdapter {
return EditorState.create({
doc: initialDoc,
plugins: [
+ createTaskListPlugin(this.schema), // Move task list plugin FIRST
+ createListIndentationPlugin(this.schema), // Add list indentation plugin
...exampleSetup({ schema: this.schema, menuBar: false }),
keymap(this.menuCommandFactory.buildKeymap()),
createTriggerPlugin(
From dad3a990e668e5813b325eafdcf4d15e8d045c27 Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 14:24:55 +0200
Subject: [PATCH 4/9] feat: add markdown support for task lists
- Add task list serialization to markdown (- [ ] and - [x] syntax)
- Extend markdown parser whitelist for task list classes and input elements
- Add support for remark-gfm generated contains-task-list class
- Add HTML transformation to wrap task list text in paragraph elements
- Enable bidirectional conversion between markdown and task lists
---
src/components/markdown/markdown-parser.ts | 79 +++++++++++++++++++
.../text-editor/utils/markdown-converter.ts | 17 +++-
2 files changed, 95 insertions(+), 1 deletion(-)
diff --git a/src/components/markdown/markdown-parser.ts b/src/components/markdown/markdown-parser.ts
index f8c4bd34e0..fb32faf7b2 100644
--- a/src/components/markdown/markdown-parser.ts
+++ b/src/components/markdown/markdown-parser.ts
@@ -47,6 +47,53 @@ export async function markdownToHTML(
})
.use(() => {
return (tree: Node) => {
+ // Transform task list items to wrap text content in paragraphs
+ visit(tree, 'element', (node: any) => {
+ if (
+ node.tagName === 'li' &&
+ node.properties?.className?.includes('task-list-item')
+ ) {
+ const newChildren = [];
+ let textContent = [];
+
+ for (const child of node.children || []) {
+ const isInput =
+ child.type === 'element' &&
+ child.tagName === 'input';
+ const isTextOrInline =
+ child.type === 'text' ||
+ (child.type === 'element' &&
+ child.tagName !== 'p');
+
+ if (isInput) {
+ newChildren.push(child);
+ } else if (isTextOrInline) {
+ textContent.push(child);
+ } else {
+ if (textContent.length > 0) {
+ newChildren.push({
+ type: 'element',
+ tagName: 'p',
+ children: textContent,
+ });
+ textContent = [];
+ }
+ newChildren.push(child);
+ }
+ }
+
+ if (textContent.length > 0) {
+ newChildren.push({
+ type: 'element',
+ tagName: 'p',
+ children: textContent,
+ });
+ }
+
+ node.children = newChildren;
+ }
+ });
+
// Run the sanitizeStyle function on all elements, to sanitize
// the value of the `style` attribute, if there is one.
visit(tree, 'element', sanitizeStyle);
@@ -99,6 +146,8 @@ function getWhiteList(allowedComponents: CustomElementDefinition[]): Schema {
...defaultSchema,
tagNames: [
...(defaultSchema.tagNames || []),
+ 'input', // Explicitly allow input elements for task list checkboxes
+ 'limel-checkbox', // Allow limel-checkbox component for task lists
...allowedComponents.map((component) => component.tagName),
],
attributes: {
@@ -108,6 +157,36 @@ function getWhiteList(allowedComponents: CustomElementDefinition[]): Schema {
['className', 'MsoNormal'],
], // Allow the class 'MsoNormal' on
elements
a: [...(defaultSchema.attributes.a ?? []), 'referrerpolicy'], // Allow referrerpolicy on elements
+ // Allow task list specific classes and attributes
+ ul: [
+ ...(defaultSchema.attributes.ul ?? []),
+ ['className', 'task-list'],
+ ['className', 'contains-task-list'], // Allow remark-gfm generated class
+ ],
+ li: [
+ ...(defaultSchema.attributes.li ?? []),
+ ['className', 'task-list-item'],
+ ],
+ div: [
+ ...(defaultSchema.attributes.div ?? []),
+ ['className', 'task-list-item-content'],
+ ],
+ input: [
+ ...(defaultSchema.attributes.input ?? []),
+ 'type',
+ 'checked',
+ 'disabled',
+ ],
+ // Allow limel-checkbox attributes
+ 'limel-checkbox': [
+ 'checked',
+ 'disabled',
+ 'readonly',
+ 'invalid',
+ 'required',
+ 'indeterminate',
+ ['className'],
+ ],
'*': asteriskAttributeWhitelist,
},
};
diff --git a/src/components/text-editor/utils/markdown-converter.ts b/src/components/text-editor/utils/markdown-converter.ts
index f27ddaa06a..ceee9b91a3 100644
--- a/src/components/text-editor/utils/markdown-converter.ts
+++ b/src/components/text-editor/utils/markdown-converter.ts
@@ -51,6 +51,19 @@ const buildMarkdownSerializer = (
...defaultMarkdownSerializer.nodes,
...getImageNodeMarkdownSerializer(language),
...customNodes,
+ // Task list serialization
+ task_list: (state: MarkdownSerializerState, node: ProseMirrorNode) => {
+ state.renderList(node, '', () => '');
+ },
+ task_list_item: (
+ state: MarkdownSerializerState,
+ node: ProseMirrorNode
+ ) => {
+ const checked = node.attrs.checked;
+ const checkbox = checked ? '[x]' : '[ ]';
+ state.write(`- ${checkbox} `);
+ state.renderContent(node);
+ },
};
const marks = {
@@ -78,7 +91,9 @@ export class MarkdownConverter implements ContentTypeConverter {
this.customNodes = plugins;
}
public parseAsHTML = (text: string): Promise => {
- return markdownToHTML(text, { whitelist: this.customNodes });
+ return markdownToHTML(text, {
+ whitelist: this.customNodes,
+ });
};
public serialize = (view: EditorView): string => {
From cf64cc7682de02c53e94f86405ad6f2ac62aabba Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 14:25:06 +0200
Subject: [PATCH 5/9] feat: add Tab/Shift-Tab indentation for lists
- Add universal list indentation plugin for both regular and task lists
- Support Tab key to indent (nest deeper) list items
- Support Shift-Tab key to outdent (nest shallower) list items
- Maintain editor focus when Tab is pressed outside list contexts
- Use dedicated task list commands for proper indentation handling
---
.../plugins/list-indentation-plugin.ts | 90 +++++++++++++++++++
1 file changed, 90 insertions(+)
create mode 100644 src/components/text-editor/prosemirror-adapter/plugins/list-indentation-plugin.ts
diff --git a/src/components/text-editor/prosemirror-adapter/plugins/list-indentation-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/list-indentation-plugin.ts
new file mode 100644
index 0000000000..95d7e160dd
--- /dev/null
+++ b/src/components/text-editor/prosemirror-adapter/plugins/list-indentation-plugin.ts
@@ -0,0 +1,90 @@
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { keymap } from 'prosemirror-keymap';
+import { Schema } from 'prosemirror-model';
+import { liftListItem, sinkListItem } from 'prosemirror-schema-list';
+import {
+ liftTaskListItem,
+ sinkTaskListItem,
+} from './task-list/task-list-commands';
+
+const listIndentationKey = new PluginKey('listIndentation');
+
+export const createListIndentationPlugin = (schema: Schema): Plugin => {
+ const listItemType = schema.nodes.list_item;
+ const taskListItemType = schema.nodes.task_list_item;
+
+ // Create the task list commands
+ const sinkTaskList = sinkTaskListItem(schema);
+ const liftTaskList = liftTaskListItem(schema);
+
+ console.log('List indentation plugin initialized');
+ console.log('listItemType:', listItemType);
+ console.log('taskListItemType:', taskListItemType);
+
+ if (!listItemType && !taskListItemType) {
+ console.log('No list types found in schema');
+ return new Plugin({
+ key: listIndentationKey,
+ });
+ }
+
+ return keymap({
+ Tab: (state, dispatch) => {
+ console.log('Tab key pressed in list indentation plugin');
+ const { selection } = state;
+ const { $from } = selection;
+
+ // Check if we're in any type of list item
+ for (let d = $from.depth; d >= 0; d--) {
+ const node = $from.node(d);
+ console.log(`Tab - Depth ${d}:`, node.type.name, node);
+
+ // Handle regular list items
+ if (listItemType && node.type === listItemType) {
+ console.log('Indenting regular list item');
+ return sinkListItem(listItemType)(state, dispatch);
+ }
+
+ // Handle task list items
+ if (taskListItemType && node.type === taskListItemType) {
+ console.log('Indenting task list item');
+ return sinkTaskList(state, dispatch);
+ }
+ }
+
+ console.log('Not in a list item, consuming Tab to keep focus');
+ // Return true to prevent default browser Tab behavior (losing focus)
+ // but don't dispatch any changes since we're not in a list
+ return true;
+ },
+ 'Shift-Tab': (state, dispatch) => {
+ console.log('Shift-Tab key pressed in list indentation plugin');
+ const { selection } = state;
+ const { $from } = selection;
+
+ // Check if we're in any type of list item
+ for (let d = $from.depth; d >= 0; d--) {
+ const node = $from.node(d);
+ console.log(`Shift-Tab - Depth ${d}:`, node.type.name, node);
+
+ // Handle regular list items
+ if (listItemType && node.type === listItemType) {
+ console.log('Outdenting regular list item');
+ return liftListItem(listItemType)(state, dispatch);
+ }
+
+ // Handle task list items
+ if (taskListItemType && node.type === taskListItemType) {
+ console.log('Outdenting task list item');
+ return liftTaskList(state, dispatch);
+ }
+ }
+
+ console.log(
+ 'Not in a list item, consuming Shift-Tab to keep focus'
+ );
+ // Return true to prevent default browser Shift-Tab behavior
+ return true;
+ },
+ });
+};
From 450ecb18e58da9cc867cc29580c897fbae0b47d9 Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 14:25:21 +0200
Subject: [PATCH 6/9] feat: add task list example and documentation
- Add comprehensive task list example component with markdown content
- Include example of both task lists and regular lists for comparison
- Add keyboard shortcuts documentation in example
- Add task list example reference to text editor component documentation
---
.../examples/text-editor-with-task-lists.tsx | 52 +++++++++++++++++++
src/components/text-editor/text-editor.tsx | 1 +
2 files changed, 53 insertions(+)
create mode 100644 src/components/text-editor/examples/text-editor-with-task-lists.tsx
diff --git a/src/components/text-editor/examples/text-editor-with-task-lists.tsx b/src/components/text-editor/examples/text-editor-with-task-lists.tsx
new file mode 100644
index 0000000000..53ff7d9c9a
--- /dev/null
+++ b/src/components/text-editor/examples/text-editor-with-task-lists.tsx
@@ -0,0 +1,52 @@
+import { Component, h, State } from '@stencil/core';
+/**
+ * Task list example
+ *
+ * This example demonstrates the task list functionality in the text editor.
+ * You can create interactive checkbox lists that can be toggled and managed.
+ */
+@Component({
+ tag: 'limel-example-text-editor-with-task-lists',
+ shadow: true,
+})
+export class TextEditorTaskListExample {
+ @State()
+ private value: string = `# Task List Example
+
+Here's an example with task lists:
+
+- [ ] First unchecked task
+- [x] This task is completed
+- [ ] Another unchecked task
+- [ ] Task with some **bold** text
+
+You can click the checkbox button in the toolbar to create more task lists!
+
+## Regular list for comparison
+
+- Regular bullet point
+- Another bullet point
+- Third bullet point
+
+## Keyboard shortcuts
+- **Enter**: Create new list item
+- **Tab**: Indent list item (nest deeper)
+- **Shift+Tab**: Outdent list item (nest shallower)
+`;
+
+ public render() {
+ return [
+ ,
+ ,
+ ];
+ }
+
+ private handleChange = (event: CustomEvent) => {
+ this.value = event.detail;
+ };
+}
diff --git a/src/components/text-editor/text-editor.tsx b/src/components/text-editor/text-editor.tsx
index 7cf8deb602..0f2cde8b1b 100644
--- a/src/components/text-editor/text-editor.tsx
+++ b/src/components/text-editor/text-editor.tsx
@@ -26,6 +26,7 @@ import { EditorUiType } from './types';
* @exampleComponent limel-example-text-editor-with-markdown
* @exampleComponent limel-example-text-editor-with-html
* @exampleComponent limel-example-text-editor-with-tables
+ * @exampleComponent limel-example-text-editor-with-task-lists
* @exampleComponent limel-example-text-editor-with-inline-images-file-storage
* @exampleComponent limel-example-text-editor-with-inline-images-base64
* @exampleComponent limel-example-text-editor-allow-resize
From 606b203c65e40feb0b5f077a2b53a2d3a491b8cb Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 14:25:30 +0200
Subject: [PATCH 7/9] chore: update package-lock.json
Update package-lock.json with dependency changes from task list implementation
---
package-lock.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package-lock.json b/package-lock.json
index 96a6f3fa8a..0a6735c46c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23496,7 +23496,7 @@
"integrity": "sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ==",
"dev": true,
"requires": {
- "prosemirror-model": ">=1.22.1",
+ "prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
From 8f2415c1520fa23785a479c561b8a1c024139882 Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 14:46:00 +0200
Subject: [PATCH 8/9] fix(markdown): Add proper task list support with
interactive checkboxes
- Remove bullet points from task list items
- Add flexbox layout for proper checkbox and text alignment
- Make checkboxes interactive and properly styled
- Remove paragraph wrapping that caused text to appear on new lines
- Add hover and focus states for better UX
- Update composite example to showcase task list functionality
Fixes issues where:
- Task list checkboxes appeared disabled/non-clickable
- Text content wrapped to new lines
- Bullet points still appeared alongside checkboxes
---
.../markdown/examples/markdown-composite.tsx | 41 ++++++---
src/components/markdown/markdown-parser.ts | 51 +----------
src/components/markdown/markdown.tsx | 23 +++--
.../markdown/partial-styles/_lists.scss | 84 ++++++++++++++++++-
4 files changed, 131 insertions(+), 68 deletions(-)
diff --git a/src/components/markdown/examples/markdown-composite.tsx b/src/components/markdown/examples/markdown-composite.tsx
index 8d5acdf61e..120fafd4ed 100644
--- a/src/components/markdown/examples/markdown-composite.tsx
+++ b/src/components/markdown/examples/markdown-composite.tsx
@@ -12,21 +12,36 @@ import { Component, State, h } from '@stencil/core';
})
export class MarkdownRenderContentExample {
@State()
- private markdown = '# Hello, world!\n\nThis is **markdown**!';
+ private markdown = `# Hello, world!
+
+This is **markdown**!
+
+- [x] test
+- [x] test
+
+## Task Lists
+
+- [ ] This is an unchecked task
+- [x] This is a completed task
+ - [ ] Nested unchecked task
+ - [x] Nested completed task
+- [ ] Another unchecked task`;
public render() {
- return [
- ,
- ,
- ];
+ return (
+
+
+
+
+ );
}
private handleMarkdownChange = (
diff --git a/src/components/markdown/markdown-parser.ts b/src/components/markdown/markdown-parser.ts
index fb32faf7b2..03ad7dfc69 100644
--- a/src/components/markdown/markdown-parser.ts
+++ b/src/components/markdown/markdown-parser.ts
@@ -47,53 +47,10 @@ export async function markdownToHTML(
})
.use(() => {
return (tree: Node) => {
- // Transform task list items to wrap text content in paragraphs
- visit(tree, 'element', (node: any) => {
- if (
- node.tagName === 'li' &&
- node.properties?.className?.includes('task-list-item')
- ) {
- const newChildren = [];
- let textContent = [];
-
- for (const child of node.children || []) {
- const isInput =
- child.type === 'element' &&
- child.tagName === 'input';
- const isTextOrInline =
- child.type === 'text' ||
- (child.type === 'element' &&
- child.tagName !== 'p');
-
- if (isInput) {
- newChildren.push(child);
- } else if (isTextOrInline) {
- textContent.push(child);
- } else {
- if (textContent.length > 0) {
- newChildren.push({
- type: 'element',
- tagName: 'p',
- children: textContent,
- });
- textContent = [];
- }
- newChildren.push(child);
- }
- }
-
- if (textContent.length > 0) {
- newChildren.push({
- type: 'element',
- tagName: 'p',
- children: textContent,
- });
- }
-
- node.children = newChildren;
- }
- });
-
+ // Remove the task list paragraph wrapping transformation
+ // as it causes layout issues. Task lists work better with
+ // direct text content and CSS flexbox layout.
+
// Run the sanitizeStyle function on all elements, to sanitize
// the value of the `style` attribute, if there is one.
visit(tree, 'element', sanitizeStyle);
diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx
index 93b87f157e..723e84ed41 100644
--- a/src/components/markdown/markdown.tsx
+++ b/src/components/markdown/markdown.tsx
@@ -69,6 +69,7 @@ export class Markdown {
this.rootElement.innerHTML = html;
this.setupImageIntersectionObserver();
+ this.setupTaskListHandlers();
} catch (error) {
console.error(error);
}
@@ -86,12 +87,22 @@ export class Markdown {
}
public render() {
- return [
- (this.rootElement = el as HTMLDivElement)}
- />,
- ];
+ return
(this.rootElement = el)} />;
+ }
+
+ private setupTaskListHandlers() {
+ // Make task list checkboxes interactive
+ const checkboxes = this.rootElement.querySelectorAll(
+ '.task-list-item input[type="checkbox"]'
+ );
+
+ for (const checkbox of checkboxes) {
+ (checkbox as HTMLInputElement).addEventListener('change', () => {
+ // Checkbox state is already updated by the browser
+ // This is a read-only markdown display, so we don't
+ // need to sync back to the value prop
+ });
+ }
}
private setupImageIntersectionObserver() {
diff --git a/src/components/markdown/partial-styles/_lists.scss b/src/components/markdown/partial-styles/_lists.scss
index 7be8767685..f911504582 100644
--- a/src/components/markdown/partial-styles/_lists.scss
+++ b/src/components/markdown/partial-styles/_lists.scss
@@ -1,8 +1,12 @@
ul {
list-style: none;
+ margin-top: 0.25rem;
+ padding-left: 0;
+
li {
position: relative;
margin-left: 0.75rem;
+ margin-bottom: 0.25rem;
&:before {
content: '';
@@ -17,6 +21,15 @@ ul {
background-color: rgb(var(--contrast-700));
display: block;
}
+
+ // Task list items should not have bullet points
+ &.task-list-item {
+ margin-left: 0;
+
+ &:before {
+ display: none;
+ }
+ }
}
}
@@ -25,9 +38,76 @@ ol {
padding-left: 1rem;
}
-ul {
- margin-top: 0.25rem;
+// Task list specific styles
+.task-list,
+.contains-task-list {
+ list-style: none;
padding-left: 0;
+
+ .task-list-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ margin-bottom: 0.25rem;
+ margin-left: 0;
+
+ &:before {
+ display: none; // Remove bullet points
+ }
+
+ input[type="checkbox"] {
+ margin: 0;
+ margin-top: 0.125rem; // Align with first line of text
+ flex-shrink: 0;
+ width: 1rem;
+ height: 1rem;
+ cursor: pointer;
+
+ // Ensure checkbox is interactive
+ pointer-events: auto;
+
+ // Style to match other form elements
+ border: 1px solid rgb(var(--contrast-600));
+ border-radius: 0.125rem;
+ background-color: rgb(var(--contrast-100));
+
+ &:checked {
+ background-color: rgb(var(--color-sky-default));
+ border-color: rgb(var(--color-sky-default));
+ }
+
+ &:hover {
+ border-color: rgb(var(--contrast-800));
+ }
+
+ &:focus {
+ outline: 2px solid rgb(var(--color-sky-light));
+ outline-offset: 1px;
+ }
+ }
+
+ // Handle both paragraph-wrapped and direct text content
+ p {
+ margin: 0;
+ flex: 1;
+ line-height: 1.5;
+ }
+
+ // Direct text content (when not wrapped in paragraphs)
+ > * {
+ &:not(input):not(ul):not(ol) {
+ flex: 1;
+ line-height: 1.5;
+ }
+ }
+
+ // Nested task lists
+ .task-list,
+ .contains-task-list {
+ margin-top: 0.25rem;
+ margin-left: 1.5rem;
+ }
+ }
}
ul ul,
From 2e83bf8331e31c52a9bd9efa8903aca52cde9d93 Mon Sep 17 00:00:00 2001
From: Viktor Eriksson <32965939+viktorSoftDev@users.noreply.github.com>
Date: Tue, 30 Sep 2025 15:01:45 +0200
Subject: [PATCH 9/9] feat(markdown): Add interactive task list support with
two-way binding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove disabled attribute from GFM-generated checkboxes to make them interactive
- Add taskListChange event emitter to limel-markdown component
- Implement checkbox click handlers that update source markdown text
- Add two-way binding in composite example for real-time sync
- Parse task list positions and map to corresponding markdown lines
- Support nested task lists with proper indentation preservation
- Use safe regex patterns to avoid backtracking vulnerabilities
Interactive features:
- Click checkboxes in rendered markdown to toggle state
- Automatically updates source markdown (- [ ] ↔ - [x])
- Real-time sync between textarea and rendered output
- Preserves indentation and formatting in nested lists
Fixes the core issue where remark-gfm generates disabled checkboxes by default,
making them truly interactive for task management workflows.
---
.../markdown/examples/markdown-composite.tsx | 16 +++++-
src/components/markdown/markdown-parser.ts | 18 +++++--
src/components/markdown/markdown.tsx | 49 ++++++++++++++++---
.../markdown/partial-styles/_lists.scss | 14 +++---
4 files changed, 78 insertions(+), 19 deletions(-)
diff --git a/src/components/markdown/examples/markdown-composite.tsx b/src/components/markdown/examples/markdown-composite.tsx
index 120fafd4ed..ed28f8872b 100644
--- a/src/components/markdown/examples/markdown-composite.tsx
+++ b/src/components/markdown/examples/markdown-composite.tsx
@@ -1,4 +1,7 @@
-import { LimelInputFieldCustomEvent } from '@limetech/lime-elements';
+import {
+ LimelInputFieldCustomEvent,
+ LimelMarkdownCustomEvent,
+} from '@limetech/lime-elements';
import { Component, State, h } from '@stencil/core';
/**
@@ -38,7 +41,10 @@ This is **markdown**!
/>
);
@@ -49,4 +55,10 @@ This is **markdown**!
) => {
this.markdown = event.detail;
};
+
+ private handleTaskListChange = (
+ event: LimelMarkdownCustomEvent
+ ) => {
+ this.markdown = event.detail;
+ };
}
diff --git a/src/components/markdown/markdown-parser.ts b/src/components/markdown/markdown-parser.ts
index 03ad7dfc69..0763aa8391 100644
--- a/src/components/markdown/markdown-parser.ts
+++ b/src/components/markdown/markdown-parser.ts
@@ -47,10 +47,20 @@ export async function markdownToHTML(
})
.use(() => {
return (tree: Node) => {
- // Remove the task list paragraph wrapping transformation
- // as it causes layout issues. Task lists work better with
- // direct text content and CSS flexbox layout.
-
+ // Make task list checkboxes interactive by removing the disabled attribute
+ // that remark-gfm adds by default
+ visit(tree, 'element', (node: any) => {
+ if (
+ node.tagName === 'input' &&
+ node.properties?.type === 'checkbox' &&
+ node.properties?.disabled !== undefined
+ ) {
+ // Check if this checkbox is inside a task list item
+ // We can identify this by looking for the task-list-item class in parent
+ delete node.properties.disabled;
+ }
+ });
+
// Run the sanitizeStyle function on all elements, to sanitize
// the value of the `style` attribute, if there is one.
visit(tree, 'element', sanitizeStyle);
diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx
index 723e84ed41..5259d32123 100644
--- a/src/components/markdown/markdown.tsx
+++ b/src/components/markdown/markdown.tsx
@@ -1,4 +1,4 @@
-import { Component, h, Prop, Watch } from '@stencil/core';
+import { Component, h, Prop, Watch, Event, EventEmitter } from '@stencil/core';
import { markdownToHTML } from './markdown-parser';
import { globalConfig } from '../../global/config';
import { CustomElementDefinition } from '../../global/shared-types/custom-element.types';
@@ -55,6 +55,13 @@ export class Markdown {
@Prop()
public lazyLoadImages = false;
+ /**
+ * Emitted when a task list checkbox is clicked.
+ * The event detail contains the updated markdown text.
+ */
+ @Event()
+ public taskListChange: EventEmitter;
+
@Watch('value')
public async textChanged() {
try {
@@ -91,16 +98,46 @@ export class Markdown {
}
private setupTaskListHandlers() {
- // Make task list checkboxes interactive
+ // Make task list checkboxes interactive and sync back to markdown
const checkboxes = this.rootElement.querySelectorAll(
'.task-list-item input[type="checkbox"]'
);
+ // Parse the current markdown to find task list items
+ const lines = this.value.split('\n');
+ let taskListIndex = 0;
+
for (const checkbox of checkboxes) {
- (checkbox as HTMLInputElement).addEventListener('change', () => {
- // Checkbox state is already updated by the browser
- // This is a read-only markdown display, so we don't
- // need to sync back to the value prop
+ const inputElement = checkbox as HTMLInputElement;
+ const currentTaskIndex = taskListIndex++;
+
+ inputElement.addEventListener('change', () => {
+ // Find the corresponding line in the markdown
+ let taskCounter = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ // Match both checked and unchecked task list items
+ // Using a more specific regex to avoid backtracking issues
+ const taskListRegex = /^(\s*)- \[([x ])\] (.+)$/;
+ const taskListMatch = taskListRegex.exec(line);
+
+ if (taskListMatch) {
+ if (taskCounter === currentTaskIndex) {
+ // Update this line
+ const indent = taskListMatch[1];
+ const newState = inputElement.checked ? 'x' : ' ';
+ const text = taskListMatch[3];
+ lines[i] = `${indent}- [${newState}] ${text}`;
+
+ // Emit the updated markdown
+ const updatedMarkdown = lines.join('\n');
+ this.taskListChange.emit(updatedMarkdown);
+ break;
+ }
+ taskCounter++;
+ }
+ }
});
}
}
diff --git a/src/components/markdown/partial-styles/_lists.scss b/src/components/markdown/partial-styles/_lists.scss
index f911504582..6e8853a88c 100644
--- a/src/components/markdown/partial-styles/_lists.scss
+++ b/src/components/markdown/partial-styles/_lists.scss
@@ -2,7 +2,7 @@ ul {
list-style: none;
margin-top: 0.25rem;
padding-left: 0;
-
+
li {
position: relative;
margin-left: 0.75rem;
@@ -55,31 +55,31 @@ ol {
display: none; // Remove bullet points
}
- input[type="checkbox"] {
+ input[type='checkbox'] {
margin: 0;
margin-top: 0.125rem; // Align with first line of text
flex-shrink: 0;
width: 1rem;
height: 1rem;
cursor: pointer;
-
+
// Ensure checkbox is interactive
pointer-events: auto;
-
+
// Style to match other form elements
border: 1px solid rgb(var(--contrast-600));
border-radius: 0.125rem;
background-color: rgb(var(--contrast-100));
-
+
&:checked {
background-color: rgb(var(--color-sky-default));
border-color: rgb(var(--color-sky-default));
}
-
+
&:hover {
border-color: rgb(var(--contrast-800));
}
-
+
&:focus {
outline: 2px solid rgb(var(--color-sky-light));
outline-offset: 1px;