Skip to content

Commit

Permalink
WIP pattern autocompleter input
Browse files Browse the repository at this point in the history
  • Loading branch information
brunopagno committed Dec 19, 2024
1 parent a489f17 commit 98892cf
Show file tree
Hide file tree
Showing 7 changed files with 273 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class SubjectConfigurationForm < ApplicationForm
end

subject_form.group(data: { "admin--subject-configuration-target": "patternInput" }) do |toggleable_group|
toggleable_group.text_field(
toggleable_group.pattern_autocompleter(
name: :pattern,
label: I18n.t("types.edit.subject_configuration.pattern.label"),
caption: I18n.t("types.edit.subject_configuration.pattern.caption"),
Expand Down
165 changes: 165 additions & 0 deletions frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/

import { Controller } from '@hotwired/stimulus';

export default class PatternAutocompleterController extends Controller {
/*
* [ ] 1. focus/highlight
* [ ] 1.1 on enter
* [ ] 1.2 on leave
* [x] 2. click on the component
* [x] 2.1 when the click hits a editable field
* [x] 2.2 when the click hits a label
* [x] 2.3 when the clicks hits only the parent div
* [x] 2.4 when the click hits the 'x' on a label
* [ ] 3. navigation
* [ ] 3.1 tab (should we navigate between editable fields, or focus/unfocus the whole field?)
* [x] 3.2 arrow keys (move between fields)
* [ ] 4. editting
* [ ] 4.1 popup autocomplete on key type
* [ ] 4.2 adjust context of autocomplete based on the current word (e.g. typing in the middle of a word)
* [ ] 4.3 backspace/delete when the next/previous character is a label
* [x] 4.4 merging fields when removing labels
* [x] 4.5 make sure there's always a editable span in the beginning and in the end
* [ ] 5. accessibility concerns?
*/

static targets = [
'labelTemplate',
'inputTemplate',
'content',
];

declare readonly labelTemplateTarget:HTMLTemplateElement;
declare readonly inputTemplateTarget:HTMLTemplateElement;
declare readonly contentTarget:HTMLElement;

static values = {
initial: String,
};

declare initialValue:string;

connect() {
if (this.initialValue.startsWith('{{')) {
this.addInputNode('');
}

this.initialValue.match(/({{.*?}})|([^{}]+)/g)?.forEach((value) => {
if (value.startsWith('{{')) {
this.addLabelNode(value.substring(2, value.length - 2));
} else {
this.addInputNode(value);
}
});

if (this.initialValue.endsWith('}}')) {
this.addInputNode('');
}
}

click(event:PointerEvent) {
const target = event.target as HTMLElement;

if (target === this.contentTarget) {
const inputs = this.contentTarget.children;
(inputs[inputs.length - 1] as HTMLElement).focus();
}
}

remove_label(event:PointerEvent) {
const target = event.target as HTMLElement;

if (target) {
target.remove();
this.handleSequentialInputFields();
}
}

input_keydown(event:KeyboardEvent) {
const selection = window.getSelection();

if (!selection || selection.type.toLowerCase() !== 'caret') { return; }

const element = event.target as HTMLElement;
if (event.key === 'ArrowLeft' && selection?.anchorOffset === 0) {
const nextElement = element.previousElementSibling?.previousElementSibling as HTMLElement;
nextElement.focus();
if (nextElement) {
const range = document.createRange();
range.selectNodeContents(nextElement);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
if (event.key === 'ArrowRight' && selection?.anchorOffset === selection?.anchorNode?.textContent?.length) {
const nextElement = element.nextElementSibling?.nextElementSibling as HTMLElement;
nextElement.focus();
if (nextElement) {
selection.setPosition(nextElement, 0);
}
}
}

private addLabelNode(valueText:string) {
const target = this.labelTemplateTarget.content?.cloneNode(true) as DocumentFragment;
(target.firstElementChild as HTMLElement).innerText = valueText;
this.contentTarget.appendChild(target);
}

private addInputNode(valueText:string) {
const target = this.inputTemplateTarget.content?.cloneNode(true) as DocumentFragment;
(target.firstElementChild as HTMLElement).innerText = this.withSurroundingWhitespaces(valueText);
this.contentTarget.appendChild(target);
}

private withSurroundingWhitespaces(value:string):string {
return value
.replace(/^\s+/, (spaces) => spaces.replace(/ /g, '\u00A0'))
.replace(/\s+$/, (spaces) => spaces.replace(/ /g, '\u00A0'));
}

private handleSequentialInputFields() {
const tokens = this.contentTarget.children;
for (let i = 0; i < tokens.length - 1; /* do nothing */) {
const current = tokens[i];
const next = tokens[i + 1];

if (current.getAttribute('contenteditable') && next.getAttribute('contenteditable')) {
current.innerHTML += next.innerHTML;
next.remove();
} else {
i += 1;
}
}
}
}
2 changes: 2 additions & 0 deletions frontend/src/stimulus/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import OpShowWhenValueSelectedController from './controllers/show-when-value-sel
import FlashController from './controllers/flash.controller';
import OpProjectsZenModeController from './controllers/dynamic/projects/zen-mode.controller';
import PasswordConfirmationDialogController from './controllers/password-confirmation-dialog.controller';
import PatternAutocompleterController from './controllers/pattern-autocompleter.controller';

declare global {
interface Window {
Expand Down Expand Up @@ -41,3 +42,4 @@ instance.register('show-when-checked', OpShowWhenCheckedController);
instance.register('show-when-value-selected', OpShowWhenValueSelectedController);
instance.register('table-highlighting', TableHighlightingController);
instance.register('projects-zen-mode', OpProjectsZenModeController);
instance.register('pattern-autocompleter', PatternAutocompleterController);
4 changes: 4 additions & 0 deletions lib/primer/open_project/forms/dsl/input_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ def autocompleter(**, &)
add_input AutocompleterInput.new(builder:, form:, **, &)
end

def pattern_autocompleter(**, &)
add_input PatternAutocompleterInput.new(builder: @builder, form: @form, **, &)
end

def color_select_list(**, &)
add_input ColorSelectInput.new(builder:, form:, **, &)
end
Expand Down
32 changes: 32 additions & 0 deletions lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module Primer
module OpenProject
module Forms
module Dsl
class PatternAutocompleterInput < Primer::Forms::Dsl::Input
attr_reader :name, :label

def initialize(name:, label:, **system_arguments)
@name = name
@label = label

super(**system_arguments)
end

def to_component
PatternAutocompleter.new(nil)
end

def type
:pattern_autocompleter
end

def focusable?
true
end
end
end
end
end
end
53 changes: 53 additions & 0 deletions lib/primer/open_project/forms/pattern_autocompleter.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<style>
.pattern-autocompleter {
-moz-appearance: textfield;
-webkit-appearance: textfield;
background-color: white;
background-color: -moz-field;
border: 1px solid lightgray;
font: -moz-field;
font: -webkit-small-control;
padding: 6px;
min-height: 35px;
}
.pattern-autocompleter:focus {
border-color: cadetblue;
}

.pattern-autocompleter div {
display: inline;
}

.pattern-autocompleter div[contenteditable] {
border: none;
padding: 4px 2px;
}
.pattern-autocompleter div[contenteditable]:focus {
outline: none;
}
</style>

<%= @pattern.blueprint %>
<div class="pattern-autocompleter"
data-controller="pattern-autocompleter"
data-pattern-autocompleter-initial-value="<%= @pattern.blueprint %>">
<template data-pattern-autocompleter-target="labelTemplate">
<%=
render(
Primer::Beta::Label.new(
tag: :div,
scheme: :accent,
inline: true,
"data-action" => "click->pattern-autocompleter#remove_label"
)
) { "__VALUE__" }
%>
</template>

<template data-pattern-autocompleter-target="inputTemplate">
<div contenteditable="true" data-action="keydown->pattern-autocompleter#input_keydown">__VALUE__</span>
</template>
<div data-pattern-autocompleter-target="content"
data-action="click->pattern-autocompleter#click">
</div>
</div>
16 changes: 16 additions & 0 deletions lib/primer/open_project/forms/pattern_autocompleter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module Primer
module OpenProject
module Forms
class PatternAutocompleter < Primer::Forms::BaseComponent
DEFAULT_PATTERN = Types::Pattern.new(blueprint: "{{author}} Vacation - {{start_date}} - {{end_date}} A", enabled: true)

def initialize(pattern)
super()
@pattern = pattern || DEFAULT_PATTERN
end
end
end
end
end

0 comments on commit 98892cf

Please sign in to comment.