aboutsummaryrefslogtreecommitdiff
path: root/static/ts/components/input-list.ts
blob: 72d27cab3c681ce8923e081b4cee3cc9b0769e2e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
/**
 * `<input-list />`
 *
 * A list of identical input fields, which forms a group. For example
 * useful to handle keywords.
 *
 * @category Web Components
 * @mergeTarget components
 * @module
 */
export { InputList }

/*
  TODO allow each item to be a larger unit, possibly containing multiple input
  fields.
*/
/**
   A multi-valued input, done by creating extra input fields as needed.

   The first element of body MUST be an input element, which will be used as the
   template for each instance. A tag input could for example look like

   @example
   ```html
   <input-list name="tags">
    <input type="text" placeholder="tag ..." />
   </input-list>
   ```

   Whenever one of the input elements `value` becomes the empty string, that tag
   is removed, and whenever there is no element with the empty string as a
   `value`, a new input element will be added onto the end.
 */
class InputList extends HTMLElement {

    /** The element used as our template. Will be sourced from the initial HTML code. */
    #el: HTMLInputElement;

    /**
       Registered listeners, which will be added onto each created entry

       Keys are event names ('input', 'change', ...) and values event handlers.

       This is a list of tuples rather than a dictionary, since multiple
       listeners of the same type can be registered.
    */
    #listeners: [string, (e: Event) => void][] = [];

    constructor() {
        super();
        this.#el = this.children[0].cloneNode(true) as HTMLInputElement;
    }

    /** Clears all existing children upon mount */
    connectedCallback() {
        for (let child of this.children) {
            child.remove();
        }
        this.#addInstance();
    }

    /**
       Instanciates a new instance of the input element.

       An event listener for 'input' will be added, which will handle the
       addition and removing of other elements.

       All event listeners attachet on the input-list component will also be
       added.
    */
    #createInstance(): HTMLInputElement {
        let new_el = this.#el.cloneNode(true) as HTMLInputElement
        let that = this;
        new_el.addEventListener('input', function() {
            /* TODO .value is empty both if it's actually empty, but also
               for invalid input. Check new_el.validity, and new_el.validationMessage
            */
            if (new_el.value === '') {
                let sibling = (this.previousElementSibling || this.nextElementSibling)
                /* Only remove ourselves if we have siblings
                   Otherwise we just linger */
                if (sibling) {
                    this.remove();
                    (sibling as HTMLInputElement).focus();
                }
            } else {
                if (!this.nextElementSibling) {
                    that.#addInstance();
                    // window.setTimeout(() => this.focus())
                    this.focus();
                }
            }
        });

        for (let [type, proc] of this.#listeners) {
            new_el.addEventListener(type, proc);
        }

        return new_el;
    }

    /** Add a new instance of the input element to the container */
    #addInstance() {
        let new_el = this.#createInstance();
        this.appendChild(new_el);
    }

    /**
     * The value from each element, except the last which should always be empty.
     * Has an unspecified type, since children:s value field might give non-strings.
     */
    get value(): unknown[] {
        let value_list = []
        for (let child of this.children) {
            value_list.push((child as any).value);
        }
        if (value_list[value_list.length - 1] === '') {
            value_list.pop();
        }
        return value_list
    }

    /**
       Overwrite the current value with a new one.

       Each entry in the array will be mapped unto one instance of the template
       input element. A final empty element will also be added.
     */
    set value(new_value: any[]) {

        let all_equal = true;
        for (let i = 0; i < this.children.length; i++) {
            let sv = (this.children[i] as any).value
            all_equal
                &&= (sv == new_value[i])
                || (sv === '' && new_value[i] == undefined)
        }
        if (all_equal) return;

        /* Copy our current input elements into a dictionary.
           This allows us to only create new elements where needed
        */
        let values = new Map;
        for (let child of this.children) {
            values.set((child as HTMLInputElement).value, child);
        }

        let output_list: HTMLInputElement[] = []
        for (let value of new_value) {
            let element;
            /* Only create element if needed */
            if ((element = values.get(value))) {
                output_list.push(element)
                /* clear dictionary */
                values.set(value, false);
            } else {
                let new_el = this.#createInstance();
                new_el.value = value;
                output_list.push(new_el);
            }
        }
        /* final, trailing, element */
        output_list.push(this.#createInstance());

        this.replaceChildren(...output_list);
    }

    /**
       Add an event listener to each of the inputs.

       This basically works as the "regular" version.
     */
    addEventListener(type: string, proc: ((e: Event) => void)) {
        // if (type != 'input') throw "Only input supported";

        this.#listeners.push([type, proc])

        for (let child of this.children) {
            child.addEventListener(type, proc);
        }
    }
}