-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
a489f17
commit 98892cf
Showing
7 changed files
with
273 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
165 changes: 165 additions & 0 deletions
165
frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
53
lib/primer/open_project/forms/pattern_autocompleter.html.erb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |