drab 6.0.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { define } from "../util/define.js";
2
+ import { Announcer } from "./index.js";
3
+ define("drab-announcer", Announcer);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Use the `Announcer` element to create a visually hidden ARIA live region
3
+ * that announces content changes to screen readers. Use this element when you
4
+ * need to announce changes to screen readers that something has changed. If changed
5
+ * element is visible on the page, add the appropriate ARIA live attribute to the
6
+ * visible element instead of using this announcer.
7
+ *
8
+ * It's recommended to create this element with JavaScript using the `Announcer.init` method,
9
+ * then you can reuse the same announcer throughout the application to
10
+ * [avoid duplicate regions](https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-2/#limit-the-number-of-live-regions-on-the-page)
11
+ * (see below).
12
+ *
13
+ * `aria-live`
14
+ *
15
+ * By default, the announcer is created with the
16
+ * [`polite` ARIA live attribute](https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-1/#1.-using-the-aria-live-attribute).
17
+ *
18
+ * @example
19
+ *
20
+ * ```ts
21
+ * import { Announcer } from "drab/announcer";
22
+ *
23
+ * // creates and appends a new announcer to the body element
24
+ * const announcer = Announcer.init();
25
+ *
26
+ * // create announcement
27
+ * announcer.announce("message");
28
+ * ```
29
+ *
30
+ * > The `Base` element creates a single `Announcer` to share between all elements
31
+ * > that can be accessed through `this.announce`. If you are using one of drab's other
32
+ * > elements you can call `announce` directly on the element to announce changes.
33
+ */
34
+ export declare class Announcer extends HTMLElement {
35
+ constructor();
36
+ connectedCallback(): void;
37
+ /**
38
+ * @param message message to announce to screen readers
39
+ */
40
+ announce(message: string): void;
41
+ /**
42
+ * Helper method to create a new `Announcer` element named `drab-announcer`
43
+ * and append the element to the `<body>` tag. If an announcer already exists
44
+ * on the page it will return the existing element.
45
+ *
46
+ * @returns the created or existing `Announcer` element
47
+ */
48
+ static init(): Announcer;
49
+ }
@@ -0,0 +1,80 @@
1
+ import { define } from "../util/define.js";
2
+ /**
3
+ * Use the `Announcer` element to create a visually hidden ARIA live region
4
+ * that announces content changes to screen readers. Use this element when you
5
+ * need to announce changes to screen readers that something has changed. If changed
6
+ * element is visible on the page, add the appropriate ARIA live attribute to the
7
+ * visible element instead of using this announcer.
8
+ *
9
+ * It's recommended to create this element with JavaScript using the `Announcer.init` method,
10
+ * then you can reuse the same announcer throughout the application to
11
+ * [avoid duplicate regions](https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-2/#limit-the-number-of-live-regions-on-the-page)
12
+ * (see below).
13
+ *
14
+ * `aria-live`
15
+ *
16
+ * By default, the announcer is created with the
17
+ * [`polite` ARIA live attribute](https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-1/#1.-using-the-aria-live-attribute).
18
+ *
19
+ * @example
20
+ *
21
+ * ```ts
22
+ * import { Announcer } from "drab/announcer";
23
+ *
24
+ * // creates and appends a new announcer to the body element
25
+ * const announcer = Announcer.init();
26
+ *
27
+ * // create announcement
28
+ * announcer.announce("message");
29
+ * ```
30
+ *
31
+ * > The `Base` element creates a single `Announcer` to share between all elements
32
+ * > that can be accessed through `this.announce`. If you are using one of drab's other
33
+ * > elements you can call `announce` directly on the element to announce changes.
34
+ */
35
+ export class Announcer extends HTMLElement {
36
+ constructor() {
37
+ super();
38
+ }
39
+ connectedCallback() {
40
+ this.style.position = "absolute";
41
+ this.style.width = "1px";
42
+ this.style.height = "1px";
43
+ this.style.padding = "0";
44
+ this.style.margin = "-1px";
45
+ this.style.overflow = "hidden";
46
+ this.style.clipPath = "rect(0, 0, 0, 0)";
47
+ this.style.whiteSpace = "nowrap";
48
+ this.style.borderWidth = "0";
49
+ if (!this.ariaLive)
50
+ this.ariaLive = "polite";
51
+ }
52
+ /**
53
+ * @param message message to announce to screen readers
54
+ */
55
+ announce(message) {
56
+ // this ensures multiple messages will be read in succession
57
+ const span = document.createElement("span");
58
+ span.textContent = message;
59
+ this.append(span);
60
+ // https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-2/#empty-the-live-region-and-wait-a-bit-in-between-updates
61
+ setTimeout(() => span.remove(), 10000);
62
+ }
63
+ /**
64
+ * Helper method to create a new `Announcer` element named `drab-announcer`
65
+ * and append the element to the `<body>` tag. If an announcer already exists
66
+ * on the page it will return the existing element.
67
+ *
68
+ * @returns the created or existing `Announcer` element
69
+ */
70
+ static init() {
71
+ define("drab-announcer", this);
72
+ const name = "drab-announcer";
73
+ let announcer = document.querySelector(name);
74
+ if (!announcer) {
75
+ announcer = document.createElement(name);
76
+ document.body.append(announcer);
77
+ }
78
+ return announcer;
79
+ }
80
+ }
@@ -19,6 +19,7 @@ export class BaseCopy extends Base {
19
19
  * @param text The `text` to share
20
20
  */
21
21
  async copy(text = this.value) {
22
+ this.announce(`copied ${text} to clipboard`);
22
23
  await navigator.clipboard.writeText(text);
23
24
  this.swapContent();
24
25
  }
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { Base } from "./index.js";
2
- customElements.define("drab-base", Base);
3
+ define("drab-base", Base);
@@ -27,14 +27,18 @@ export declare class Base extends HTMLElement {
27
27
  */
28
28
  get event(): keyof HTMLElementEventMap;
29
29
  set event(value: keyof HTMLElementEventMap);
30
+ /**
31
+ * @param message message to announce to screen readers
32
+ */
33
+ announce(message: string): void;
30
34
  /**
31
35
  * @returns All of the elements that match the `trigger` selector.
32
36
  * @default this.querySelectorAll("[data-trigger]")
33
37
  */
34
38
  getTrigger<T extends HTMLElement = HTMLElement>(): NodeListOf<T>;
35
39
  /**
36
- * @param instance The instance of the desired element, ex: `HTMLDialogElement`.
37
- * Defaults to `HTMLElement`.
40
+ * @param instance The instance of the desired element to validate against,
41
+ * ex: `HTMLDialogElement`. Defaults to `HTMLElement`.
38
42
  * @returns The element that matches the `content` selector.
39
43
  * @default this.querySelector("[data-content]")
40
44
  */
@@ -67,16 +71,12 @@ export declare class Base extends HTMLElement {
67
71
  * The reason for this is to make these elements work better with frameworks like Svelte. For SSR this isn't necessary, but when client side rendering, the HTML within the custom element isn't available before `connectedCallback` is called. By waiting until the next microtask, the HTML content is available---then for example, listeners can be attached to elements inside.
68
72
  */
69
73
  mount(): void;
70
- /**
71
- * Called when custom element is added to the page.
72
- */
74
+ /** Called when custom element is added to the page. */
73
75
  connectedCallback(): void;
74
76
  /**
75
77
  * Passed into `disconnectedCallback`, since `Base` needs to run `disconnectedCallback` as well. It is overridden in each element that needs to run `disconnectedCallback`.
76
78
  */
77
79
  destroy(): void;
78
- /**
79
- * Called when custom element is removed from the page.
80
- */
80
+ /** Called when custom element is removed from the page. */
81
81
  disconnectedCallback(): void;
82
82
  }
@@ -1,3 +1,4 @@
1
+ import { Announcer } from "../announcer/index.js";
1
2
  /**
2
3
  * Each element in the library extends the `Base` class. It provides methods
3
4
  * for selecting elements via HTML attributes along with other helpers.
@@ -12,8 +13,11 @@
12
13
  */
13
14
  export class Base extends HTMLElement {
14
15
  /**
15
- * To clean up event listeners added to `document` when the element is removed.
16
+ * A single `Announcer` element to share between all drab elements to announce
17
+ * interactive changes.
16
18
  */
19
+ static #announcer = Announcer.init();
20
+ /** To clean up event listeners added to `document` when the element is removed. */
17
21
  #listenerController = new AbortController();
18
22
  constructor() {
19
23
  super();
@@ -31,6 +35,12 @@ export class Base extends HTMLElement {
31
35
  set event(value) {
32
36
  this.setAttribute("event", value);
33
37
  }
38
+ /**
39
+ * @param message message to announce to screen readers
40
+ */
41
+ announce(message) {
42
+ Base.#announcer.announce(message);
43
+ }
34
44
  /**
35
45
  * @returns All of the elements that match the `trigger` selector.
36
46
  * @default this.querySelectorAll("[data-trigger]")
@@ -40,8 +50,8 @@ export class Base extends HTMLElement {
40
50
  return triggers;
41
51
  }
42
52
  /**
43
- * @param instance The instance of the desired element, ex: `HTMLDialogElement`.
44
- * Defaults to `HTMLElement`.
53
+ * @param instance The instance of the desired element to validate against,
54
+ * ex: `HTMLDialogElement`. Defaults to `HTMLElement`.
45
55
  * @returns The element that matches the `content` selector.
46
56
  * @default this.querySelector("[data-content]")
47
57
  */
@@ -115,9 +125,7 @@ export class Base extends HTMLElement {
115
125
  * The reason for this is to make these elements work better with frameworks like Svelte. For SSR this isn't necessary, but when client side rendering, the HTML within the custom element isn't available before `connectedCallback` is called. By waiting until the next microtask, the HTML content is available---then for example, listeners can be attached to elements inside.
116
126
  */
117
127
  mount() { }
118
- /**
119
- * Called when custom element is added to the page.
120
- */
128
+ /** Called when custom element is added to the page. */
121
129
  connectedCallback() {
122
130
  queueMicrotask(() => this.mount());
123
131
  }
@@ -125,9 +133,7 @@ export class Base extends HTMLElement {
125
133
  * Passed into `disconnectedCallback`, since `Base` needs to run `disconnectedCallback` as well. It is overridden in each element that needs to run `disconnectedCallback`.
126
134
  */
127
135
  destroy() { }
128
- /**
129
- * Called when custom element is removed from the page.
130
- */
136
+ /** Called when custom element is removed from the page. */
131
137
  disconnectedCallback() {
132
138
  this.destroy();
133
139
  this.#listenerController.abort();
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { ContextMenu } from "./index.js";
2
- customElements.define("drab-contextmenu", ContextMenu);
3
+ define("drab-contextmenu", ContextMenu);
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { Copy } from "./index.js";
2
- customElements.define("drab-copy", Copy);
3
+ define("drab-copy", Copy);
package/dist/define.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as elements from "./index.js";
2
- for (const [key, value] of Object.entries(elements)) {
3
- customElements.define(`drab-${key.toLowerCase()}`, value);
2
+ import { define } from "./util/define.js";
3
+ for (const [name, Constructor] of Object.entries(elements)) {
4
+ define(`drab-${name.toLowerCase()}`, Constructor);
4
5
  }
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { Dialog } from "./index.js";
2
- customElements.define("drab-dialog", Dialog);
3
+ define("drab-dialog", Dialog);
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { Editor } from "./index.js";
2
- customElements.define("drab-editor", Editor);
3
+ define("drab-editor", Editor);
@@ -1,5 +1,10 @@
1
1
  import { Base, type BaseAttributes } from "../base/index.js";
2
2
  export type EditorAttributes = BaseAttributes;
3
+ export type EditorTriggerAttributes = {
4
+ "data-value": string;
5
+ "data-key": string;
6
+ "data-type": "block" | "wrap" | "inline";
7
+ };
3
8
  /**
4
9
  * A piece of content to insert into the `textarea`.
5
10
  */
@@ -42,7 +47,7 @@ export type ContentElement = {
42
47
  */
43
48
  export declare class Editor extends Base {
44
49
  #private;
45
- /** The characters that will be automatically closed when typed. */
50
+ /** Characters that will be automatically closed when typed. */
46
51
  keyPairs: {
47
52
  [key: string]: string;
48
53
  };
@@ -29,9 +29,11 @@ import { Base } from "../base/index.js";
29
29
  * - When text is highlighted and a `wrap` character `keyPair` is typed, the highlighted text will be wrapped with the character instead of removing it. For example, if a word is highlighted and the `"` character is typed, the work will be surrounded by `"`s.
30
30
  */
31
31
  export class Editor extends Base {
32
- /** Array of keyPair characters that have been opened. */
32
+ /** Array of `keyPair` characters that have been opened. */
33
33
  #openChars = [];
34
- /** The characters that will be automatically closed when typed. */
34
+ /** Keys that will reset the type over for keyPairs */
35
+ #resetKeys = new Set(["ArrowUp", "ArrowDown", "Delete"]);
36
+ /** Characters that will be automatically closed when typed. */
35
37
  keyPairs = {
36
38
  "(": ")",
37
39
  "{": "}",
@@ -44,9 +46,8 @@ export class Editor extends Base {
44
46
  super();
45
47
  // add any `type: "wrap"` values from `contentElements` to `keyPairs`
46
48
  for (const element of this.#contentElements) {
47
- if (element.type === "wrap") {
49
+ if (element.type === "wrap")
48
50
  this.keyPairs[element.value] = element.value;
49
- }
50
51
  }
51
52
  }
52
53
  /** The `content`, expects an `HTMLTextAreaElement`. */
@@ -60,221 +61,150 @@ export class Editor extends Base {
60
61
  set text(value) {
61
62
  this.textArea.value = value;
62
63
  }
63
- /** An array of `ContentElement`s derived from each `trigger`'s data attributes. */
64
+ /** Array of `ContentElement`s derived from each `trigger`'s data attributes. */
64
65
  get #contentElements() {
65
66
  const contentElements = [];
66
67
  for (const trigger of this.getTrigger()) {
67
- contentElements.push(this.#getContentElement(trigger));
68
+ contentElements.push(trigger.dataset);
68
69
  }
69
70
  return contentElements;
70
71
  }
71
- /**
72
- * - splits the content by "```" and finds the current index
73
- * of the selectionStart
74
- *
75
- * @returns current codeblock (index) of selectionStart
76
- */
77
- get #currentBlock() {
78
- const blocks = this.text.split("```");
79
- let totalChars = 0;
80
- for (const [i, block] of blocks.entries()) {
81
- totalChars += block.length + 3;
82
- if (this.#selectionStart < totalChars) {
83
- return i;
84
- }
85
- }
86
- return 0;
87
- }
88
72
  /** Gets the end position of the selection */
89
- get #selectionEnd() {
73
+ get #selEnd() {
90
74
  return this.textArea.selectionEnd;
91
75
  }
92
76
  /** Gets the start position of the selection. */
93
- get #selectionStart() {
77
+ get #selStart() {
94
78
  return this.textArea.selectionStart;
95
79
  }
96
- /** Sets the current cursor selection in the `textarea` */
97
- #setSelectionRange(start, end) {
98
- this.textArea.setSelectionRange(start, end);
80
+ /**
81
+ * @param str string to insert into `text`
82
+ * @param index where to insert the string
83
+ */
84
+ #insertStr(str, index) {
85
+ this.text = this.text.slice(0, index) + str + this.text.slice(index);
99
86
  }
100
87
  /**
101
- * @param trigger The trigger html element.
102
- * @returns The ContentElement based on the `trigger`'s attributes.
88
+ * @param start Starting index for removal.
89
+ * @param end Optional ending index - defaults to start + 1 to remove 1 character.
103
90
  */
104
- #getContentElement(trigger) {
105
- const type = trigger.dataset.type;
106
- const value = trigger.dataset.value;
107
- const key = trigger.dataset.key ?? undefined;
108
- return { type, value, key };
91
+ #removeStr(start, end = start + 1) {
92
+ this.text = this.text.slice(0, start) + this.text.slice(end);
93
+ }
94
+ /** Sets the current cursor selection in the `textarea` */
95
+ #setSelection(start, end = start) {
96
+ this.textArea.setSelectionRange(start, end);
97
+ this.textArea.focus();
109
98
  }
110
99
  /**
111
- * - Inserts text into the `textarea` based on the `display` property of
112
- * the `ContentElement`.
100
+ * Inserts text and sets selection based on the `ContentElement` selected.
113
101
  *
114
- * @param el the content element
115
- * @param selectionStart current start position the selection
116
- * @param selectionEnd current end position of the selection
102
+ * @param content
117
103
  */
118
- async #insertText(el, selectionStart, selectionEnd) {
119
- if (el.type === "inline") {
104
+ #addContent({ value, type }) {
105
+ let start = this.#selStart;
106
+ if (type === "inline") {
120
107
  // insert at current position
121
- this.text = `${this.text.slice(0, selectionEnd)}${el.value}${this.text.slice(selectionEnd)}`;
108
+ this.#insertStr(value, start);
109
+ const match = /[a-z]+/i.exec(value);
110
+ if (match?.index != null) {
111
+ start += match.index;
112
+ this.#setSelection(start, start + match[0].length);
113
+ }
114
+ else {
115
+ this.#setSelection(start + value.length);
116
+ }
122
117
  }
123
- else if (el.type === "wrap") {
124
- this.text = insertChar(this.text, el.value, selectionStart);
125
- this.text = insertChar(this.text, this.keyPairs[el.value], selectionEnd + el.value.length);
118
+ else if (type === "wrap") {
119
+ const end = this.#selEnd + value.length;
120
+ this.#insertStr(value, start);
121
+ this.#insertStr(this.keyPairs[value], end);
122
+ this.#setSelection(start + value.length, end);
126
123
  // if single char, add to opened
127
- if (el.value.length < 2)
128
- this.#openChars.push(el.value);
124
+ if (value.length === 1)
125
+ this.#openChars.push(value);
129
126
  }
130
- else if (el.type === "block") {
131
- const { lines, lineNumber } = this.#getLineInfo();
132
- const firstChar = el.value.at(0);
133
- // add the string to the beginning of the line
127
+ else {
128
+ // "block"
129
+ const { lines, lineNumber } = this.#lineMeta();
130
+ // avoids `# # # `, instead adds trimmed => `### `
131
+ const firstChar = value[0];
134
132
  if (firstChar && lines[lineNumber]?.startsWith(firstChar)) {
135
- // avoids `# # # `, instead adds trimmed => `### `
136
- lines[lineNumber] = el.value.trim() + lines[lineNumber];
137
- }
138
- else {
139
- lines[lineNumber] = el.value + lines[lineNumber];
133
+ value = value.trim();
140
134
  }
135
+ // add the string to the beginning of the line
136
+ lines[lineNumber] = value + lines[lineNumber];
141
137
  this.text = lines.join("\n");
138
+ this.#setSelection(start + value.length);
142
139
  }
143
140
  }
144
141
  /**
145
- * - Sets the caret position after text is inserted based on
146
- * the length of the text.
147
- * - Highlights text if the content contains any letters.
142
+ * Checks if there is a block element at the beginning of the string.
148
143
  *
149
- * @param text
150
- * @param selectionStart current start position the selection
151
- * @param selectionEnd current end position of the selection
144
+ * @param line
145
+ * @returns Whatever is found, otherwise null
152
146
  */
153
- async #setCaretPosition(text, selectionStart, selectionEnd) {
154
- let startPos = 0;
155
- let endPos = 0;
156
- if (/[a-z]/i.test(text)) {
157
- // if string contains letters, highlight the first word
158
- for (let i = selectionEnd; i < this.text.length; i++) {
159
- if (this.text[i]?.match(/[a-z]/i)) {
160
- if (!startPos) {
161
- startPos = i;
162
- }
163
- else {
164
- endPos = i + 1;
165
- }
166
- }
167
- else if (startPos) {
168
- break;
169
- }
170
- }
171
- }
172
- else {
173
- // leave the cursor in place
174
- startPos = selectionStart + text.length;
175
- endPos = selectionEnd + text.length;
147
+ #startsWithBlock(line) {
148
+ for (const blockString of this.#contentElements
149
+ .filter((el) => el.type === "block")
150
+ .map((el) => el.value)) {
151
+ if (line.startsWith(blockString.trim()))
152
+ return blockString;
176
153
  }
177
- this.#setSelectionRange(startPos, endPos);
178
- this.textArea.focus();
179
- }
180
- /**
181
- * - Inserts the text and then sets the caret position
182
- * based on the `ContentElement` selected.
183
- *
184
- * @param el selected content element
185
- */
186
- async #addContent(el) {
187
- const selectionEnd = this.#selectionEnd;
188
- const selectionStart = this.#selectionStart;
189
- await this.#insertText(el, selectionStart, selectionEnd);
190
- this.#setCaretPosition(el.value, selectionStart, selectionEnd);
154
+ return null;
191
155
  }
192
156
  /**
193
- * - checks if there is a block element or a number
194
- * at the beginning of the string
195
- *
196
- * @param str
197
- * @returns what is found, or the empty string
157
+ * @param line
158
+ * @returns The number, if the line starts with a number and a period.
198
159
  */
199
- #getRepeat(str) {
200
- if (str) {
201
- const blockStrings = [];
202
- this.#contentElements.forEach((el) => {
203
- if (el.type === "block")
204
- blockStrings.push(el.value);
205
- });
206
- for (let i = 0; i < blockStrings.length; i++) {
207
- const repeatString = blockStrings[i];
208
- if (repeatString && str.startsWith(repeatString)) {
209
- return repeatString;
210
- }
211
- }
212
- const repeatNum = startsWithNumberAndPeriod(str);
213
- if (repeatNum)
214
- return `${repeatNum}. `;
215
- }
216
- return "";
160
+ #startsWithNumberAndPeriod(line) {
161
+ const match = line.match(/^(\d+)\./);
162
+ return match ? Number(match[1]) : null;
217
163
  }
218
164
  /**
219
- * @returns lines as an array, current line number, current column number
220
- *
221
- * @example
222
- *
223
- * ```js
224
- * const { lines, lineNumber, columnNumber } = getLineInfo();
225
- * ```
165
+ * @returns Metadata describing the current position of the selection.
226
166
  */
227
- #getLineInfo() {
167
+ #lineMeta() {
228
168
  const lines = this.text.split("\n");
229
- let characterCount = 0;
230
- for (let i = 0; i < lines.length; i++) {
231
- const lineLength = lines.at(i)?.length ?? 0;
232
- // for each line
233
- characterCount++; // account for removed "\n" due to .split()
234
- characterCount += lineLength;
169
+ let charCount = 0;
170
+ for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
171
+ const line = lines[lineNumber];
172
+ const len = line.length + 1; // account for removed "\n" due to .split()
173
+ charCount += len;
235
174
  // find the line that the cursor is on
236
- if (characterCount > this.#selectionEnd) {
175
+ if (charCount > this.#selEnd) {
237
176
  return {
177
+ line,
238
178
  lines,
239
- lineNumber: i,
240
- columnNumber: this.#selectionEnd - (characterCount - lineLength - 1),
179
+ lineNumber,
180
+ columnNumber: this.#selEnd - (charCount - len),
241
181
  };
242
182
  }
243
183
  }
244
- return { lines, lineNumber: 0, columnNumber: 0 };
184
+ return { line: lines[0], lines, lineNumber: 0, columnNumber: 0 };
245
185
  }
246
186
  /**
247
- * - Increments/decrements the start of following lines if they are numbers
187
+ * Increments/decrements the start of following lines if they are numbers.
248
188
  *
249
- * Prevents this:
189
+ * @param decrement if following lines should be decremented instead of incremented
250
190
  *
251
- * ```
252
- * 1. presses enter here when two items in list
253
- * 2.
254
- * 2.
255
- * ```
191
+ * @example
256
192
  *
257
- * Instead:
193
+ * ```md
194
+ * Prevents this, instead fixes the following lines.
258
195
  *
259
- * ```
260
- * 1.
196
+ * 1. presses enter here when two items in list
261
197
  * 2.
262
- * 3.
198
+ * 2. (repeat of 2)
263
199
  * ```
264
- *
265
- * @param currentLineNumber
266
- * @param decrement if following lines should be decremented instead of incremented
267
200
  */
268
- #correctFollowing(currentLineNumber, decrement = false) {
269
- const { lines } = this.#getLineInfo();
270
- for (let i = currentLineNumber + 1; i < lines.length; i++) {
271
- const line = lines[i];
201
+ #correctFollowing(decrement = false) {
202
+ let { lines, lineNumber } = this.#lineMeta();
203
+ for (; ++lineNumber < lines.length;) {
204
+ let line = lines[lineNumber];
272
205
  if (line) {
273
- const num = startsWithNumberAndPeriod(line);
274
- if (!num) {
275
- break;
276
- }
277
- else {
206
+ const num = this.#startsWithNumberAndPeriod(line);
207
+ if (num) {
278
208
  let newNum;
279
209
  if (decrement) {
280
210
  if (num > 1) {
@@ -287,194 +217,135 @@ export class Editor extends Base {
287
217
  else {
288
218
  newNum = num + 1;
289
219
  }
290
- lines[i] = line.slice(String(num).length); // remove number from start
291
- lines[i] = String(newNum) + lines[i];
220
+ lines[lineNumber] = String(newNum) + line.slice(String(num).length);
221
+ }
222
+ else {
223
+ break;
292
224
  }
293
225
  }
294
226
  }
227
+ const start = this.#selStart;
295
228
  this.text = lines.join("\n");
229
+ this.#setSelection(start);
296
230
  }
297
231
  mount() {
298
- this.textArea.addEventListener("keydown", async (e) => {
299
- // these keys will reset the type over for characters like "
300
- const resetKeys = ["ArrowUp", "ArrowDown", "Delete"];
301
- const nextChar = this.text[this.#selectionEnd] ?? "";
302
- if (resetKeys.includes(e.key)) {
303
- // reset
232
+ this.textArea.addEventListener("keydown", (e) => {
233
+ const nextChar = this.text[this.#selEnd] ?? "";
234
+ const notHighlighted = this.#selStart === this.#selEnd;
235
+ if (this.#resetKeys.has(e.key)) {
304
236
  this.#openChars = [];
305
237
  }
306
238
  else if (e.key === "Backspace") {
307
- const prevChar = this.text[this.#selectionStart - 1];
239
+ const prevChar = this.text[this.#selStart - 1];
308
240
  if (prevChar &&
309
241
  prevChar in this.keyPairs &&
310
242
  nextChar === this.keyPairs[prevChar]) {
311
243
  // remove both characters if the next one is the match of the prev
312
244
  e.preventDefault();
313
- const start = this.#selectionStart - 1;
314
- const end = this.#selectionEnd - 1;
315
- this.text = removeChar(this.text, start);
316
- this.text = removeChar(this.text, end);
317
- setTimeout(() => {
318
- this.#setSelectionRange(start, end);
319
- }, 0);
245
+ const start = this.#selStart - 1;
246
+ const end = this.#selEnd - 1;
247
+ this.#removeStr(start);
248
+ this.#removeStr(end);
249
+ this.#setSelection(start, end);
320
250
  this.#openChars.pop();
321
251
  }
322
- if (prevChar === "\n" && this.#selectionStart === this.#selectionEnd) {
323
- // see `correctFollowing`
252
+ else if (prevChar === "\n" && this.#selStart === this.#selEnd) {
324
253
  e.preventDefault();
325
- const newPos = this.#selectionStart - 1;
326
- const { lineNumber } = this.#getLineInfo();
327
- this.#correctFollowing(lineNumber, true);
328
- this.text = removeChar(this.text, newPos);
329
- setTimeout(async () => {
330
- this.#setSelectionRange(newPos, newPos);
331
- }, 0);
254
+ const newPos = this.#selStart - 1;
255
+ this.#correctFollowing(true);
256
+ this.#removeStr(newPos);
257
+ this.#setSelection(newPos, newPos);
332
258
  }
333
259
  }
334
260
  else if (e.key === "Tab") {
335
- if (this.#currentBlock % 2 !== 0) {
336
- // if caret is inside of a codeblock, indent
337
- e.preventDefault();
338
- await this.#addContent({
339
- type: "inline",
340
- value: "\t",
341
- });
261
+ const blocks = this.text.split("```");
262
+ let totalChars = 0;
263
+ for (const [i, block] of blocks.entries()) {
264
+ totalChars += block.length + 3;
265
+ if (totalChars > this.#selStart) {
266
+ // found
267
+ if (i % 2) {
268
+ // if caret is inside of a codeblock, indent
269
+ e.preventDefault();
270
+ this.#addContent({ type: "inline", value: "\t" });
271
+ }
272
+ break;
273
+ }
342
274
  }
275
+ // TODO add shift tab backwards
343
276
  }
344
- else if (e.key === "Enter") {
277
+ else if (e.key === "Enter" && notHighlighted) {
345
278
  // autocomplete start of next line if block or number
346
- const { lines, lineNumber, columnNumber } = this.#getLineInfo();
347
- const currentLine = lines.at(lineNumber);
348
- let repeat = this.#getRepeat(currentLine);
349
- const original = repeat;
350
- const num = startsWithNumberAndPeriod(repeat);
351
- // line starts with number and period? - increment
352
- if (num)
353
- repeat = `${num + 1}. `;
354
- if (repeat && original.length < columnNumber) {
355
- e.preventDefault();
279
+ const { line, columnNumber } = this.#lineMeta();
280
+ let repeat = this.#startsWithBlock(line);
281
+ if (!repeat) {
282
+ const num = this.#startsWithNumberAndPeriod(line);
356
283
  if (num)
357
- this.#correctFollowing(lineNumber);
358
- await this.#addContent({
359
- type: "inline",
360
- value: `\n${repeat}`,
361
- });
284
+ repeat = `${num + 1}. `;
362
285
  }
363
- else if (repeat && original.length === columnNumber) {
364
- // remove if the repeat and caret at the end of the original
286
+ if (repeat) {
365
287
  e.preventDefault();
366
- // have to set a placeholder since `this.#selectionEnd` will change
367
- // as characters are being removed
368
- const originalSelectionEnd = this.#selectionEnd;
369
- // go back the the length of the original
370
- const newPos = originalSelectionEnd - original.length;
371
- // for each character in the original
372
- for (let i = 0; i < original.length; i++) {
373
- this.text = removeChar(this.text, originalSelectionEnd - (i + 1));
288
+ if (repeat.length < columnNumber) {
289
+ // repeat same on next line
290
+ this.#addContent({ type: "inline", value: "\n" + repeat });
291
+ this.#correctFollowing();
374
292
  }
375
- setTimeout(async () => {
376
- this.#setSelectionRange(newPos, newPos);
377
- this.textArea.focus();
378
- await this.#addContent({
379
- type: "inline",
380
- value: `\n`,
381
- });
382
- }, 0);
383
- }
384
- }
385
- else {
386
- const nextCharIsClosing = Object.values(this.keyPairs).includes(nextChar);
387
- const highlighted = this.#selectionStart !== this.#selectionEnd;
388
- if (e.ctrlKey || e.metaKey) {
389
- if (this.#selectionStart === this.#selectionEnd) {
390
- // no selection
391
- if (e.key === "c" || e.key === "x") {
392
- // copy or cut entire line
393
- e.preventDefault();
394
- const { lines, lineNumber, columnNumber } = this.#getLineInfo();
395
- await navigator.clipboard.writeText(`${lineNumber === 0 && e.key === "x" ? "" : "\n"}${lines[lineNumber]}`);
396
- if (e.key === "x") {
397
- const newPos = this.#selectionStart - columnNumber;
398
- lines.splice(lineNumber, 1);
399
- this.text = lines.join("\n");
400
- setTimeout(() => {
401
- this.#setSelectionRange(newPos, newPos);
402
- }, 0);
403
- }
404
- }
293
+ else {
294
+ // remove repeat from current line
295
+ const end = this.#selEnd;
296
+ const newPos = end - repeat.length;
297
+ this.#removeStr(newPos, end);
298
+ this.#setSelection(newPos);
405
299
  }
406
300
  }
407
- if ((e.ctrlKey || e.metaKey) && e.key) {
408
- // keyboard shortcut
409
- const matchedEl = this.#contentElements.find((el) => el.key === e.key);
410
- if (matchedEl)
411
- this.#addContent(matchedEl);
412
- }
413
- else if (nextCharIsClosing &&
414
- (nextChar === e.key || e.key === "ArrowRight") &&
415
- this.#openChars.length &&
416
- !highlighted) {
417
- // type over the next character instead of inserting
418
- e.preventDefault();
419
- this.#setSelectionRange(this.#selectionStart + 1, this.#selectionEnd + 1);
420
- this.#openChars.pop();
421
- }
422
- else if (e.key in this.keyPairs) {
301
+ }
302
+ else if ((e.ctrlKey || e.metaKey) && e.key) {
303
+ if (notHighlighted && (e.key === "c" || e.key === "x")) {
304
+ // copy or cut entire line
423
305
  e.preventDefault();
424
- await this.#addContent({
425
- type: "wrap",
426
- value: e.key,
427
- });
428
- this.#openChars.push(e.key);
306
+ const { line, lines, lineNumber, columnNumber } = this.#lineMeta();
307
+ navigator.clipboard.writeText(line);
308
+ if (e.key === "x") {
309
+ const newPos = this.#selStart - columnNumber;
310
+ lines.splice(lineNumber, 1);
311
+ this.text = lines.join("\n");
312
+ this.#setSelection(newPos, newPos);
313
+ }
429
314
  }
315
+ const shortcut = this.#contentElements.find((el) => el.key === e.key);
316
+ if (shortcut)
317
+ this.#addContent(shortcut);
318
+ }
319
+ else if (this.#openChars.length &&
320
+ notHighlighted &&
321
+ (nextChar === e.key || e.key === "ArrowRight") &&
322
+ Object.values(this.keyPairs).includes(nextChar)) {
323
+ // type over the next character instead of inserting
324
+ e.preventDefault();
325
+ this.#setSelection(this.#selStart + 1, this.#selEnd + 1);
326
+ this.#openChars.pop();
327
+ }
328
+ else if (e.key in this.keyPairs) {
329
+ e.preventDefault();
330
+ this.#addContent({ type: "wrap", value: e.key });
331
+ this.#openChars.push(e.key);
430
332
  }
431
333
  });
432
334
  // trims the selection if there is an extra space around it
433
335
  this.textArea.addEventListener("dblclick", () => {
434
- if (this.#selectionStart !== this.#selectionEnd) {
435
- if (this.text[this.#selectionStart] === " ") {
436
- this.#setSelectionRange(this.#selectionStart + 1, this.#selectionEnd);
336
+ if (this.#selStart !== this.#selEnd) {
337
+ if (this.text[this.#selStart] === " ") {
338
+ this.#setSelection(this.#selStart + 1, this.#selEnd);
437
339
  }
438
- if (this.text[this.#selectionEnd - 1] === " ") {
439
- this.#setSelectionRange(this.#selectionStart, this.#selectionEnd - 1);
340
+ if (this.text[this.#selEnd - 1] === " ") {
341
+ this.#setSelection(this.#selStart, this.#selEnd - 1);
440
342
  }
441
343
  }
442
344
  });
443
345
  // reset #openChars on click since the cursor has changed position
444
346
  this.textArea.addEventListener("click", () => (this.#openChars = []));
445
347
  for (const trigger of this.getTrigger()) {
446
- trigger.addEventListener(this.event, () => {
447
- this.#addContent(this.#getContentElement(trigger));
448
- });
348
+ trigger.addEventListener(this.event, () => this.#addContent(trigger.dataset));
449
349
  }
450
350
  }
451
351
  }
452
- /**
453
- * @param str
454
- * @returns the number, if the string starts with a number and a period
455
- */
456
- const startsWithNumberAndPeriod = (str) => {
457
- const result = str.match(/^(\d+)\./);
458
- return result ? Number(result[1]) : null;
459
- };
460
- /**
461
- * - insert character into string at index
462
- *
463
- * @param str string to insert into
464
- * @param char characters to insert into `str`
465
- * @param index where to insert the characters
466
- * @returns the new string
467
- */
468
- const insertChar = (str, char, index) => {
469
- return str.slice(0, index) + char + str.slice(index);
470
- };
471
- /**
472
- * - remove char from string at index
473
- *
474
- * @param str string to remove the character from
475
- * @param index index of character to remove
476
- * @returns the new string
477
- */
478
- const removeChar = (str, index) => {
479
- return str.slice(0, index) + str.slice(index + 1);
480
- };
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { Fullscreen } from "./index.js";
2
- customElements.define("drab-fullscreen", Fullscreen);
3
+ define("drab-fullscreen", Fullscreen);
package/dist/index.d.ts CHANGED
@@ -1,12 +1,13 @@
1
- export { Base, type BaseAttributes } from "./base/index.js";
2
- export { ContextMenu, type ContextMenuAttributes, } from "./contextmenu/index.js";
3
- export { Copy, type CopyAttributes } from "./copy/index.js";
4
- export { Dialog, type DialogAttributes } from "./dialog/index.js";
5
- export { Editor, type EditorAttributes } from "./editor/index.js";
6
- export { Fullscreen, type FullscreenAttributes } from "./fullscreen/index.js";
7
- export { Intersect, type IntersectAttributes } from "./intersect/index.js";
8
- export { Prefetch, type PrefetchAttributes } from "./prefetch/index.js";
9
- export { Share, type ShareAttributes } from "./share/index.js";
10
- export { TableSort, type TableSortAttributes } from "./tablesort/index.js";
11
- export { WakeLock, type WakeLockAttributes } from "./wakelock/index.js";
12
- export { YouTube, type YouTubeAttributes } from "./youtube/index.js";
1
+ export * from "./announcer/index.js";
2
+ export * from "./base/index.js";
3
+ export * from "./contextmenu/index.js";
4
+ export * from "./copy/index.js";
5
+ export * from "./dialog/index.js";
6
+ export * from "./editor/index.js";
7
+ export * from "./fullscreen/index.js";
8
+ export * from "./intersect/index.js";
9
+ export * from "./prefetch/index.js";
10
+ export * from "./share/index.js";
11
+ export * from "./tablesort/index.js";
12
+ export * from "./wakelock/index.js";
13
+ export * from "./youtube/index.js";
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
- export { Base } from "./base/index.js";
2
- export { ContextMenu, } from "./contextmenu/index.js";
3
- export { Copy } from "./copy/index.js";
4
- export { Dialog } from "./dialog/index.js";
5
- export { Editor } from "./editor/index.js";
6
- export { Fullscreen } from "./fullscreen/index.js";
7
- export { Intersect } from "./intersect/index.js";
8
- export { Prefetch } from "./prefetch/index.js";
9
- export { Share } from "./share/index.js";
10
- export { TableSort } from "./tablesort/index.js";
11
- export { WakeLock } from "./wakelock/index.js";
12
- export { YouTube } from "./youtube/index.js";
1
+ export * from "./announcer/index.js";
2
+ export * from "./base/index.js";
3
+ export * from "./contextmenu/index.js";
4
+ export * from "./copy/index.js";
5
+ export * from "./dialog/index.js";
6
+ export * from "./editor/index.js";
7
+ export * from "./fullscreen/index.js";
8
+ export * from "./intersect/index.js";
9
+ export * from "./prefetch/index.js";
10
+ export * from "./share/index.js";
11
+ export * from "./tablesort/index.js";
12
+ export * from "./wakelock/index.js";
13
+ export * from "./youtube/index.js";
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { Intersect } from "./index.js";
2
- customElements.define("drab-intersect", Intersect);
3
+ define("drab-intersect", Intersect);
@@ -54,9 +54,7 @@ export class Intersect extends Base {
54
54
  }
55
55
  }
56
56
  }
57
- }, {
58
- threshold: this.#threshold,
59
- });
57
+ }, { threshold: this.#threshold });
60
58
  for (const trigger of this.getTrigger()) {
61
59
  observer.observe(trigger);
62
60
  }
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { Prefetch } from "./index.js";
2
- customElements.define("drab-prefetch", Prefetch);
3
+ define("drab-prefetch", Prefetch);
@@ -61,12 +61,7 @@ export class Prefetch extends Base {
61
61
  // Currently, adding `prefetch` is required to fallback if `prerender` fails.
62
62
  // Possibly will be automatic in the future, in which case it can be removed.
63
63
  // https://github.com/WICG/nav-speculation/issues/162#issuecomment-1977818473
64
- prefetch: [
65
- {
66
- source: "list",
67
- urls: [url],
68
- },
69
- ],
64
+ prefetch: [{ source: "list", urls: [url] }],
70
65
  };
71
66
  if (prerender) {
72
67
  rules.prerender = rules.prefetch;
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { Share } from "./index.js";
2
- customElements.define("drab-share", Share);
3
+ define("drab-share", Share);
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { TableSort } from "./index.js";
2
- customElements.define("drab-tablesort", TableSort);
3
+ define("drab-tablesort", TableSort);
@@ -1,5 +1,9 @@
1
1
  import { Base, type BaseAttributes } from "../base/index.js";
2
2
  export type TableSortAttributes = BaseAttributes;
3
+ export type TableSortTriggerAttributes = {
4
+ "data-type": "string" | "boolean" | "number";
5
+ "data-value": string;
6
+ };
3
7
  /**
4
8
  * Wrap a `HTMLTableElement` in the `TableSort` element to have sortable column
5
9
  * headers. Set each `th` that you want to sort to the `trigger`. Set the `tbody`
@@ -20,7 +20,7 @@ export class TableSort extends Base {
20
20
  * Removes `data-asc` or `data-desc` from other triggers then sets the correct attribute on the selected trigger.
21
21
  *
22
22
  * @param trigger
23
- * @returns true if ascending, false if descending
23
+ * @returns `true` if ascending, `false` if descending
24
24
  */
25
25
  #setAttributes(trigger) {
26
26
  const asc = "data-asc";
@@ -43,11 +43,24 @@ export class TableSort extends Base {
43
43
  mount() {
44
44
  const tbody = this.getContent(HTMLTableSectionElement);
45
45
  for (const trigger of this.getTrigger()) {
46
- trigger.addEventListener(this.event, () => {
46
+ trigger.tabIndex = 0;
47
+ trigger.role = "button";
48
+ const listener = () => {
49
+ const asc = this.#setAttributes(trigger);
47
50
  Array.from(tbody.querySelectorAll("tr"))
48
- .sort(comparer(trigger, this.#setAttributes(trigger)))
51
+ .sort(comparer(trigger, asc))
49
52
  .forEach((tr) => tbody.appendChild(tr));
50
- });
53
+ this.announce(`sorted table by ${trigger.textContent} in ${asc ? "ascending" : "descending"} order`);
54
+ };
55
+ trigger.addEventListener(this.event, listener);
56
+ if (this.event === "click") {
57
+ trigger.addEventListener("keydown", (e) => {
58
+ if (e.key === "Enter" || e.key === " ") {
59
+ e.preventDefault();
60
+ listener();
61
+ }
62
+ });
63
+ }
51
64
  }
52
65
  }
53
66
  }
@@ -65,23 +78,9 @@ const comparer = (th, ascending) => {
65
78
  return collator.compare(aVal, bVal);
66
79
  }
67
80
  else if (dataType === "boolean") {
68
- /**
69
- * if value is one of these and type is boolean
70
- * it should be considered falsy
71
- * since actually `Boolean("false") === true`
72
- * @param val string pulled from the textContent or attr
73
- * @returns a boolean of the provided string
74
- */
75
- const convertToBoolean = (val) => {
76
- const falsy = ["0", "false", "null", "undefined"];
77
- if (falsy.includes(val)) {
78
- return false;
79
- }
80
- return Boolean(val);
81
- };
82
- return convertToBoolean(aVal) === convertToBoolean(bVal)
81
+ return falsyBoolean(aVal) === falsyBoolean(bVal)
83
82
  ? 0
84
- : convertToBoolean(aVal)
83
+ : falsyBoolean(aVal)
85
84
  ? -1
86
85
  : 1;
87
86
  }
@@ -107,3 +106,16 @@ const getValue = (tr, i) => {
107
106
  }
108
107
  return "";
109
108
  };
109
+ /**
110
+ * if value is one of these and type is boolean
111
+ * it should be considered falsy
112
+ * since actually `Boolean("false") === true`
113
+ * @param val string pulled from the textContent or attr
114
+ * @returns a boolean of the provided string
115
+ */
116
+ const falsyBoolean = (val) => {
117
+ if (["0", "false", "null", "undefined"].includes(val)) {
118
+ return false;
119
+ }
120
+ return Boolean(val);
121
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Define a custom element to the registry. Checks if the element is
3
+ * defined and then names the element.
4
+ *
5
+ * @param name name of the custom element
6
+ * @param Constructor custom element constructor
7
+ */
8
+ export declare const define: (name: string, Constructor: CustomElementConstructor) => void;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Define a custom element to the registry. Checks if the element is
3
+ * defined and then names the element.
4
+ *
5
+ * @param name name of the custom element
6
+ * @param Constructor custom element constructor
7
+ */
8
+ export const define = (name, Constructor) => {
9
+ if (!customElements.get(name))
10
+ customElements.define(name, Constructor);
11
+ };
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { WakeLock } from "./index.js";
2
- customElements.define("drab-wakelock", WakeLock);
3
+ define("drab-wakelock", WakeLock);
@@ -34,9 +34,11 @@ export class WakeLock extends Base {
34
34
  if (this.#wakeLockSupported() && document.visibilityState === "visible") {
35
35
  this.wakeLock = await navigator.wakeLock.request("screen");
36
36
  this.setAttribute("locked", "");
37
+ this.announce("screen wake lock activated");
37
38
  this.swapContent(false);
38
39
  this.wakeLock.addEventListener("release", () => {
39
40
  this.removeAttribute("locked");
41
+ this.announce("screen wake lock deactivated");
40
42
  this.swapContent(false);
41
43
  if (!this.#autoLock) {
42
44
  // set to null is required, used to determine if screen should be
@@ -1,2 +1,3 @@
1
+ import { define } from "../util/define.js";
1
2
  import { YouTube } from "./index.js";
2
- customElements.define("drab-youtube", YouTube);
3
+ define("drab-youtube", YouTube);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "drab",
3
3
  "description": "Interactivity for You",
4
- "version": "6.0.0",
4
+ "version": "6.1.1",
5
5
  "homepage": "https://drab.robino.dev",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -42,6 +42,14 @@
42
42
  "types": "./dist/define.d.ts",
43
43
  "default": "./dist/define.js"
44
44
  },
45
+ "./announcer": {
46
+ "types": "./dist/announcer/index.d.ts",
47
+ "default": "./dist/announcer/index.js"
48
+ },
49
+ "./announcer/define": {
50
+ "types": "./dist/announcer/define.d.ts",
51
+ "default": "./dist/announcer/define.js"
52
+ },
45
53
  "./base": {
46
54
  "types": "./dist/base/index.d.ts",
47
55
  "default": "./dist/base/index.js"