@unofficialbox/box-open-elements 0.11.0 → 0.12.0

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.
@@ -1,483 +1,139 @@
1
- import { BaseElement } from "../../core/index.js";
2
- import { boeBrandInteractiveStyles, boeFocusRingShadow, boeNeutralInteractiveStyles, } from "../../foundations/tokens/index.js";
3
- import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
4
1
  import { boePanel } from "../../foundations/geometry/index.js";
2
+ import { CommentThread } from "../comments/index.js";
5
3
  const DEFAULT_TAG_NAME = "box-annotation-thread";
6
- const escapeHtml = (value) => value
7
- .replaceAll("&", "&")
8
- .replaceAll("<", "&lt;")
9
- .replaceAll(">", "&gt;")
10
- .replaceAll('"', "&quot;")
11
- .replaceAll("'", "&#39;");
12
- const elementStyles = `
13
- /* Author display rules on parts would otherwise defeat the UA's
14
- [hidden] rule — state toggling relies on the hidden attribute. */
15
- [hidden] {
16
- display: none !important;
17
- }
18
-
19
- :host {
20
- display: block;
21
- color: inherit;
22
- font: inherit;
23
- }
24
-
25
- [part="thread"] {
26
- display: grid;
27
- gap: ${boePanel.gap};
28
- padding: ${boePanel.padding};
29
- border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 82%, transparent);
30
- border-radius: ${boePanel.radius};
31
- background: ${boePanel.background};
32
- }
33
-
34
- [part="header"] {
35
- display: grid;
36
- gap: 0.42rem;
37
- }
38
-
39
- [part="title"] {
40
- margin: 0;
41
- font: inherit;
42
- font-size: 1.1rem;
43
- font-weight: 700;
44
- color: var(--boe-token-text-text, #1f1e1b);
45
- }
46
-
47
- [part="message"] {
48
- color: var(--boe-token-text-text-secondary, #6f6f6f);
49
- line-height: 1.5;
50
- }
51
-
52
- [part="entries"] {
53
- display: grid;
54
- gap: 0.6rem;
55
- margin: 0;
56
- padding: 0;
57
- list-style: none;
58
- }
59
-
60
- [part="entry-item"] {
61
- display: block;
62
- }
63
-
64
- [part="entry"] {
65
- appearance: none;
4
+ const anchorStyles = `
5
+ [part="anchor"] {
66
6
  display: grid;
67
- inline-size: 100%;
68
- grid-template-columns: auto 1fr;
69
- gap: ${boePanel.gap};
70
- align-items: start;
71
- padding: 0.55rem;
72
- border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 48%, transparent);
7
+ gap: 0.3rem;
8
+ padding: 0.45rem 0.55rem;
9
+ border-inline-start: 2px solid color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 45%, transparent);
73
10
  border-radius: ${boePanel.radius};
74
- background: color-mix(in srgb, var(--boe-token-surface-surface, #ffffff) 78%, transparent);
75
- color: inherit;
76
- font: inherit;
77
- text-align: left;
78
- cursor: pointer;
79
- transition:
80
- transform ${boeMotionDuration.fast} ${boeMotionEasing.standard},
81
- border-color ${boeMotionDuration.fast} ${boeMotionEasing.standard},
82
- box-shadow ${boeMotionDuration.fast} ${boeMotionEasing.standard};
83
- }
84
-
85
- [part="entry-avatar"] {
86
- display: inline-grid;
87
- place-items: center;
88
- inline-size: 2rem;
89
- block-size: 2rem;
90
- border-radius: 999px;
91
- background: var(--boe-token-surface-item-surface-selected, #f2f7fd);
92
- color: var(--boe-token-surface-surface-brand, #0061d5);
93
- font-size: 0.72rem;
94
- font-weight: 700;
95
- letter-spacing: 0.06em;
96
- text-transform: uppercase;
97
- }
98
-
99
- [part="entry-copy"] {
100
- display: grid;
101
- gap: 0.24rem;
102
- }
103
-
104
- [part="entry-topline"] {
105
- display: flex;
106
- flex-wrap: wrap;
107
- align-items: center;
108
- gap: 0.35rem;
109
- }
110
-
111
- [part="entry-author"] {
112
- font-weight: 700;
113
- color: var(--boe-token-text-text, #1f1e1b);
114
- }
115
-
116
- [part="entry-tool"],
117
- [part="entry-status"] {
118
- display: inline-flex;
119
- padding: 0.18rem 0.42rem;
120
- border-radius: 999px;
121
11
  background: var(--boe-token-surface-item-surface-selected, #f2f7fd);
122
- color: var(--boe-token-text-text-secondary, #6f6f6f);
123
- font-size: 0.75rem;
124
- }
125
-
126
- [part="entry-body"] {
127
- line-height: 1.55;
128
- color: var(--boe-token-text-text, #1f1e1b);
129
- }
130
-
131
- [part="entry-time"] {
132
- color: var(--boe-token-text-text-secondary, #6f6f6f);
133
- font-size: 0.78rem;
134
- }
135
-
136
- [part="actions"] {
137
- display: flex;
138
- flex-wrap: wrap;
139
- gap: 0.5rem;
140
12
  }
141
13
 
142
- [part="action"] {
143
- appearance: none;
144
- min-height: 2rem;
145
- padding: 0.4rem 0.7rem;
146
- border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 60%, transparent);
147
- border-radius: 999px;
148
- background: var(--boe-token-surface-surface, #ffffff);
149
- color: inherit;
150
- font: inherit;
151
- cursor: pointer;
152
- }
153
-
154
- [part="empty"] {
155
- padding: ${boePanel.padding};
156
- border-radius: ${boePanel.radius};
157
- border: 1px dashed color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 70%, transparent);
158
- color: var(--boe-token-text-text-secondary, #6f6f6f);
159
- }
160
-
161
- [part="composer"] {
162
- display: grid;
163
- gap: 0.5rem;
164
- }
165
-
166
- [part="composer-label"] {
14
+ [part="anchor-location"] {
167
15
  font-size: 0.74rem;
168
16
  font-weight: 700;
169
- letter-spacing: 0.08em;
17
+ letter-spacing: 0.06em;
170
18
  text-transform: uppercase;
171
19
  color: var(--boe-token-text-text-secondary, #6f6f6f);
172
20
  }
173
21
 
174
- [part="composer-input"] {
175
- min-height: 4rem;
176
- padding: 0.55rem;
177
- border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 60%, transparent);
178
- border-radius: ${boePanel.radius};
179
- background: var(--boe-token-surface-surface, #ffffff);
180
- color: inherit;
181
- font: inherit;
22
+ /* Quoted document text, so it reads as a citation rather than as one of
23
+ the comments below it. */
24
+ [part="anchor-quote"] {
25
+ margin: 0;
26
+ color: var(--boe-token-text-text, #1f1e1b);
27
+ font-style: italic;
182
28
  line-height: 1.5;
183
- resize: vertical;
184
- }
185
-
186
- [part="composer-submit"] {
187
- appearance: none;
188
- justify-self: start;
189
- min-height: 2rem;
190
- padding: 0.4rem 0.7rem;
191
- border: 1px solid transparent;
192
- border-radius: 999px;
193
- background: var(--boe-token-surface-surface-brand, #0061d5);
194
- color: var(--boe-token-text-text-on-brand, #ffffff);
195
- font: inherit;
196
- cursor: pointer;
197
- }
198
-
199
- ${boeNeutralInteractiveStyles('[part="entry"]')}
200
- ${boeNeutralInteractiveStyles('[part="action"]')}
201
- ${boeBrandInteractiveStyles('[part="action"][data-tone="primary"]')}
202
- ${boeBrandInteractiveStyles('[part="composer-submit"]')}
203
-
204
- [part="entry"][aria-pressed="true"],
205
- [part="entry"][aria-pressed="true"]:hover:not(:disabled) {
206
- border-color: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 34%, transparent);
207
- box-shadow: ${boeFocusRingShadow};
208
- }
209
-
210
- [part="action"][data-tone="primary"] {
211
- border-color: transparent;
212
- background: var(--boe-token-surface-surface-brand, #0061d5);
213
- color: var(--boe-token-text-text-on-brand, #ffffff);
214
29
  }
215
30
  `;
216
- export class AnnotationThread extends BaseElement {
31
+ /**
32
+ * A comment thread anchored to a place in a document.
33
+ *
34
+ * This is `box-comment-thread` plus the anchor. The distinction is the whole
35
+ * point: comments stand on their own — on a file, a task, a contract clause —
36
+ * and only become annotations when something ties them to a page, a region, or
37
+ * a run of selected text. Hosts that want the former should reach for
38
+ * `box-comment-thread` directly rather than for an annotation with no anchor.
39
+ *
40
+ * Every attribute, part and event of the underlying thread is inherited
41
+ * unchanged, so this remains a drop-in for the previous implementation.
42
+ */
43
+ export class AnnotationThread extends CommentThread {
217
44
  static tagName = DEFAULT_TAG_NAME;
218
45
  static get observedAttributes() {
219
- return ["actions", "composable", "entries", "message", "selected-entry-id", "heading"];
220
- }
221
- titleEl;
222
- messageEl;
223
- entriesEl;
224
- emptyEl;
225
- actionsEl;
226
- composerEl;
227
- composerInputEl;
228
- entriesSignature = "";
229
- actionsSignature = "";
230
- get actions() {
231
- return this.parseJsonAttribute("actions", []);
46
+ return [...CommentThread.observedAttributes, "anchor"];
47
+ }
48
+ anchorEl = null;
49
+ threadStyles() {
50
+ return `${super.threadStyles()}\n${anchorStyles}`;
51
+ }
52
+ headerExtras() {
53
+ return `
54
+ <div part="anchor" hidden>
55
+ <span part="anchor-location"></span>
56
+ <blockquote part="anchor-quote" hidden></blockquote>
57
+ </div>
58
+ `;
232
59
  }
233
- set actions(value) {
234
- this.setAttribute("actions", JSON.stringify(value));
60
+ defaultHeading() {
61
+ return "Annotation Thread";
235
62
  }
236
- get composable() {
237
- return this.hasAttribute("composable");
63
+ emptyText() {
64
+ return "No annotation thread entries available.";
238
65
  }
239
- set composable(value) {
240
- this.toggleAttribute("composable", value);
66
+ defaultComposerLabel() {
67
+ return "Reply";
241
68
  }
242
- get entries() {
243
- return this.parseJsonAttribute("entries", []);
69
+ defaultPlaceholder() {
70
+ return "Add a reply";
244
71
  }
245
- set entries(value) {
246
- this.setAttribute("entries", JSON.stringify(value));
72
+ /**
73
+ * Annotations name their pill `toolLabel`. Mapping it here keeps that
74
+ * vocabulary for existing hosts while the base model stays generic.
75
+ */
76
+ entryBadge(entry) {
77
+ return entry.toolLabel ?? entry.badge;
247
78
  }
248
- get message() {
249
- return this.getAttribute("message") ?? "";
79
+ /** Where in the document this thread sits. Null when unanchored. */
80
+ get anchor() {
81
+ return this.parseJsonAttribute("anchor", null);
250
82
  }
251
- set message(value) {
83
+ set anchor(value) {
252
84
  if (!value) {
253
- this.removeAttribute("message");
85
+ this.removeAttribute("anchor");
254
86
  return;
255
87
  }
256
- this.setAttribute("message", value);
257
- }
258
- get selectedEntryId() {
259
- return this.getAttribute("selected-entry-id") ?? "";
88
+ this.setAttribute("anchor", JSON.stringify(value));
260
89
  }
261
- set selectedEntryId(value) {
262
- if (!value) {
263
- this.removeAttribute("selected-entry-id");
264
- return;
265
- }
266
- this.setAttribute("selected-entry-id", value);
267
- }
268
- get heading() {
269
- return this.getAttribute("heading") ?? "Annotation Thread";
90
+ get entries() {
91
+ return super.entries;
270
92
  }
271
- set heading(value) {
272
- this.setAttribute("heading", value);
93
+ set entries(value) {
94
+ super.entries = value;
273
95
  }
274
- parseJsonAttribute(name, fallback) {
275
- const raw = this.getAttribute(name);
276
- if (!raw) {
277
- return fallback;
278
- }
279
- try {
280
- return JSON.parse(raw);
96
+ /**
97
+ * The anchor in words.
98
+ *
99
+ * A region is described by its page rather than by its coordinates: the
100
+ * numbers locate the highlight for the renderer, but they tell a reader
101
+ * nothing, and a screen reader least of all.
102
+ */
103
+ anchorLocation(anchor) {
104
+ if (typeof anchor.page === "number" && Number.isFinite(anchor.page)) {
105
+ return anchor.region ? `Page ${anchor.page} · region` : `Page ${anchor.page}`;
281
106
  }
282
- catch {
283
- return fallback;
107
+ if (anchor.region) {
108
+ return "Region";
284
109
  }
110
+ return anchor.quote ? "Selected text" : "";
285
111
  }
286
- emitAction(actionId) {
287
- this.dispatchEvent(new CustomEvent("action", {
288
- bubbles: true,
289
- composed: true,
290
- detail: {
291
- action: actionId,
292
- selectedEntryId: this.selectedEntryId || null,
293
- },
294
- }));
295
- }
296
- emitEntrySelected(entry) {
297
- this.selectedEntryId = entry.id;
298
- this.dispatchEvent(new CustomEvent("entry-selected", {
299
- bubbles: true,
300
- composed: true,
301
- detail: entry,
302
- }));
303
- }
304
- submitComposer() {
305
- const body = this.composerInputEl.value.trim();
306
- if (!this.composable || !body) {
307
- return;
308
- }
309
- this.dispatchEvent(new CustomEvent("entry-submitted", {
310
- bubbles: true,
311
- composed: true,
312
- detail: {
313
- body,
314
- inReplyToId: this.selectedEntryId || null,
315
- },
316
- }));
317
- this.composerInputEl.value = "";
318
- }
319
- entriesKey() {
320
- return this.getAttribute("entries") ?? "";
321
- }
322
- actionsKey() {
323
- return JSON.stringify(this.actions.map(action => action.id));
324
- }
325
- rebuildEntries() {
326
- this.entriesEl.innerHTML = this.entries
327
- .map(entry => {
328
- const selected = entry.id === this.selectedEntryId;
329
- return `
330
- <li part="entry-item" role="listitem">
331
- <button
332
- type="button"
333
- part="entry"
334
- data-entry-id="${escapeHtml(entry.id)}"
335
- aria-pressed="${selected ? "true" : "false"}"
336
- >
337
- <span part="entry-avatar">${escapeHtml(entry.initials ?? entry.author.slice(0, 2).toUpperCase())}</span>
338
- <span part="entry-copy">
339
- <span part="entry-topline">
340
- <span part="entry-author">${escapeHtml(entry.author)}</span>
341
- ${entry.toolLabel ? `<span part="entry-tool">${escapeHtml(entry.toolLabel)}</span>` : ""}
342
- ${entry.status ? `<span part="entry-status">${escapeHtml(entry.status)}</span>` : ""}
343
- </span>
344
- <span part="entry-body">${escapeHtml(entry.body)}</span>
345
- ${entry.createdAt ? `<span part="entry-time">${escapeHtml(entry.createdAt)}</span>` : ""}
346
- </span>
347
- </button>
348
- </li>
349
- `;
350
- })
351
- .join("");
352
- }
353
- patchEntrySelection() {
354
- this.entriesEl.querySelectorAll('[part="entry"]').forEach(button => {
355
- const selected = button.getAttribute("data-entry-id") === this.selectedEntryId;
356
- button.setAttribute("aria-pressed", selected ? "true" : "false");
357
- });
358
- }
359
- rebuildActions() {
360
- this.actionsEl.innerHTML = this.actions
361
- .map(action => `
362
- <button type="button" part="action" data-action-id="${escapeHtml(action.id)}" data-tone="${escapeHtml(action.tone ?? "neutral")}">
363
- ${escapeHtml(action.label)}
364
- </button>
365
- `)
366
- .join("");
367
- }
368
- patchActionLabels() {
369
- // Action IDs are arbitrary strings (quotes, newlines, …) — match on the
370
- // dataset instead of interpolating them into a CSS selector.
371
- const buttons = Array.from(this.actionsEl.querySelectorAll('[part="action"]'));
372
- this.actions.forEach(action => {
373
- const button = buttons.find(candidate => candidate.dataset.actionId === action.id);
374
- if (!button) {
375
- return;
376
- }
377
- button.textContent = action.label;
378
- button.dataset.tone = action.tone ?? "neutral";
379
- });
380
- }
381
- renderTemplate() {
112
+ update() {
113
+ super.update();
382
114
  if (!this.shadowRoot) {
383
115
  return;
384
116
  }
385
- this.shadowRoot.innerHTML = `
386
- <style>${elementStyles}</style>
387
- <article part="thread">
388
- <header part="header">
389
- <h2 part="title"></h2>
390
- <div part="message" hidden></div>
391
- </header>
392
- <ul part="entries" role="list" hidden></ul>
393
- <div part="empty" hidden>No annotation thread entries available.</div>
394
- <div part="actions" hidden></div>
395
- <div part="composer" hidden>
396
- <label part="composer-label" for="annotation-thread-composer-input">Reply</label>
397
- <textarea
398
- part="composer-input"
399
- id="annotation-thread-composer-input"
400
- placeholder="Add a reply"
401
- ></textarea>
402
- <button type="button" part="composer-submit">Reply</button>
403
- </div>
404
- </article>
405
- `;
406
- this.titleEl = this.shadowRoot.querySelector('[part="title"]');
407
- this.messageEl = this.shadowRoot.querySelector('[part="message"]');
408
- this.entriesEl = this.shadowRoot.querySelector('[part="entries"]');
409
- this.emptyEl = this.shadowRoot.querySelector('[part="empty"]');
410
- this.actionsEl = this.shadowRoot.querySelector('[part="actions"]');
411
- this.composerEl = this.shadowRoot.querySelector('[part="composer"]');
412
- this.composerInputEl = this.shadowRoot.querySelector('[part="composer-input"]');
413
- }
414
- setupListeners() {
415
- this.entriesEl.addEventListener("click", event => {
416
- const button = event.target.closest('[part="entry"]');
417
- if (!button || !this.entriesEl.contains(button)) {
418
- return;
419
- }
420
- const entryId = button.getAttribute("data-entry-id");
421
- const entry = this.entries.find(item => item.id === entryId);
422
- if (entry) {
423
- this.emitEntrySelected(entry);
424
- }
425
- });
426
- this.actionsEl.addEventListener("click", event => {
427
- const button = event.target.closest('[part="action"]');
428
- if (!button || !this.actionsEl.contains(button)) {
429
- return;
430
- }
431
- const actionId = button.getAttribute("data-action-id");
432
- if (actionId) {
433
- this.emitAction(actionId);
434
- }
435
- });
436
- this.composerEl.addEventListener("click", event => {
437
- const button = event.target.closest('[part="composer-submit"]');
438
- if (button) {
439
- this.submitComposer();
440
- }
441
- });
442
- this.composerInputEl.addEventListener("keydown", event => {
443
- if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
444
- event.preventDefault();
445
- this.submitComposer();
446
- }
447
- });
448
- }
449
- update() {
450
- if (!this.entriesEl) {
117
+ this.anchorEl ??= this.shadowRoot.querySelector('[part="anchor"]');
118
+ if (!this.anchorEl) {
451
119
  return;
452
120
  }
453
- this.titleEl.textContent = this.heading;
454
- this.messageEl.hidden = !this.message;
455
- this.messageEl.textContent = this.message;
456
- const entries = this.entries;
457
- this.entriesEl.hidden = entries.length === 0;
458
- this.emptyEl.hidden = entries.length > 0;
459
- this.entriesEl.setAttribute("aria-label", `${this.heading} entries`);
460
- const nextEntries = this.entriesKey();
461
- if (nextEntries !== this.entriesSignature) {
462
- this.entriesSignature = nextEntries;
463
- this.rebuildEntries();
464
- }
465
- else {
466
- this.patchEntrySelection();
467
- }
468
- const actions = this.actions;
469
- this.actionsEl.hidden = actions.length === 0;
470
- const nextActions = this.actionsKey();
471
- if (nextActions !== this.actionsSignature || this.actionsEl.childElementCount === 0) {
472
- this.actionsSignature = nextActions;
473
- this.rebuildActions();
474
- }
475
- else {
476
- this.patchActionLabels();
121
+ const anchor = this.anchor;
122
+ const location = anchor ? this.anchorLocation(anchor) : "";
123
+ const quote = anchor?.quote ?? "";
124
+ // Hidden only when the anchor says nothing renderable — an anchor carrying
125
+ // just a region still names its page, and a quote alone still shows.
126
+ this.anchorEl.hidden = !location && !quote;
127
+ const locationEl = this.anchorEl.querySelector('[part="anchor-location"]');
128
+ if (locationEl) {
129
+ locationEl.hidden = !location;
130
+ locationEl.textContent = location;
131
+ }
132
+ const quoteEl = this.anchorEl.querySelector('[part="anchor-quote"]');
133
+ if (quoteEl) {
134
+ quoteEl.hidden = !quote;
135
+ quoteEl.textContent = quote;
477
136
  }
478
- const composable = this.composable;
479
- this.composerEl.hidden = !composable;
480
- this.composerInputEl.disabled = !composable;
481
137
  }
482
138
  }
483
139
  AnnotationThread.register();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unofficialbox/box-open-elements",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Box Open Elements: an open, framework-agnostic design system and web component library for Box-style experiences",
5
5
  "license": "MIT",
6
6
  "homepage": "https://unofficialbox.github.io/box-open-elements",