@fusion.dev/helix-headless 0.163.0 → 0.164.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.
Files changed (30) hide show
  1. package/custom-elements.json +809 -36
  2. package/dist/callout/root/callout-root.d.ts +43 -4
  3. package/dist/callout/root/callout-root.js +58 -2
  4. package/dist/code-block/patch.d.ts +42 -0
  5. package/dist/code-block/patch.js +118 -0
  6. package/dist/code-block/root/code-block-root.d.ts +37 -1
  7. package/dist/code-block/root/code-block-root.js +57 -9
  8. package/dist/color-picker/root/color-picker-root.d.ts +7 -3
  9. package/dist/color-picker/root/color-picker-root.js +7 -4
  10. package/dist/combobox/root/combobox-root.d.ts +1 -0
  11. package/dist/combobox/root/combobox-root.js +10 -1
  12. package/dist/data-table/root/data-table-root.d.ts +1 -0
  13. package/dist/data-table/root/data-table-root.js +1 -0
  14. package/dist/element-events.generated.js +7 -1
  15. package/dist/object-inspector/root/object-inspector-root.d.ts +23 -0
  16. package/dist/object-inspector/root/object-inspector-root.js +19 -1
  17. package/dist/table/grid-controller.d.ts +7 -0
  18. package/dist/table/grid-controller.js +15 -1
  19. package/dist/table/root/table-root.d.ts +10 -0
  20. package/dist/table/root/table-root.js +18 -1
  21. package/dist/video/root/video-root.js +2 -1
  22. package/dist/video-playlist/item/video-playlist-item.d.ts +8 -0
  23. package/dist/video-playlist/item/video-playlist-item.js +7 -0
  24. package/dist/video-playlist/root/video-playlist-root.d.ts +60 -0
  25. package/dist/video-playlist/root/video-playlist-root.js +55 -1
  26. package/lint.json +1 -1
  27. package/package.json +5 -5
  28. package/types/jsx.d.ts +340 -16
  29. package/types/vue.d.ts +86 -7
  30. package/types/words.d.ts +58 -0
@@ -15,8 +15,12 @@ declare const HelixHeadlessCallout_base: (new (...args: any[]) => import("@fusio
15
15
  * region around it.
16
16
  *
17
17
  * Set `dismissible` for a note a reader may put away, as Shoelace's `closable`
18
- * alert does. The button fires `helix-dismiss` and the callout stays until a
19
- * listener removes it, so the page decides whether it comes back.
18
+ * alert does. The button fires `helix-dismiss`, and unless a listener cancels
19
+ * it the callout hides itself: `open` turns false, `:state(exiting)` holds
20
+ * while its exit animations run, and then it is `hidden`. Remove it in your
21
+ * listener to take it out of the page, or set `open` to show it again, which
22
+ * holds `:state(entering)` while it comes back. Focus on its dismiss button
23
+ * moves to the next callout's, or to the tab stop beside it, once it hides.
20
24
  *
21
25
  * A callout sets a passage apart inside a document and is read where it sits.
22
26
  * For page-level chrome above the masthead that is a live region and can be
@@ -33,13 +37,19 @@ declare const HelixHeadlessCallout_base: (new (...args: any[]) => import("@fusio
33
37
  * @slot heading - A heading above the content.
34
38
  * @slot icon - Replaces the icon chosen by `tone`.
35
39
  *
36
- * @event {HelixDismissEvent} helix-dismiss - The dismiss button was pressed. The callout does not remove itself, so remove it in response. Cancelable.
40
+ * @event {HelixDismissEvent} helix-dismiss - The dismiss button was pressed. Unless a listener cancels it, the callout hides itself, and a listener may remove it instead. Cancelable: cancel it to keep the callout showing.
37
41
  *
38
42
  * @csspart content - The content region.
39
43
  * @csspart dismiss - The button that dismisses the callout, shown when `dismissible` is set.
40
44
  * @csspart dismiss-mark - The cross on the dismiss button, shown when the `dismiss-icon` slot is empty.
41
45
  * @csspart icon - The icon region, an image named for the tone.
42
46
  * @csspart icon-mark - The icon for the tone, shown when no icon is slotted.
47
+ *
48
+ * @cssstate closed - While `open` is false: the callout was dismissed, and is leaving or hidden.
49
+ * @cssstate entering - While a callout shown again by `open` runs its entrance animations.
50
+ * @cssstate exiting - While a dismissed callout runs its exit animations, before it is hidden.
51
+ * @cssstate has-heading - Something is slotted into `heading`.
52
+ * @cssstate has-icon - An icon of your own is slotted into `icon`, in place of the tone's.
43
53
  */
44
54
  export declare class HelixHeadlessCallout extends HelixHeadlessCallout_base {
45
55
  /**
@@ -47,7 +57,6 @@ export declare class HelixHeadlessCallout extends HelixHeadlessCallout_base {
47
57
  * `registerWords('de', { 'helix-callout': { toneLabels: { danger: 'Fehler' } } })`.
48
58
  */
49
59
  static words: string;
50
- protected willUpdate(changed: PropertyValues): void;
51
60
  /**
52
61
  * The dismiss button's name, read by screen readers, with `{label}` for
53
62
  * what the callout is called: its heading, or the tone's name where it has
@@ -58,6 +67,23 @@ export declare class HelixHeadlessCallout extends HelixHeadlessCallout_base {
58
67
  * read as written.
59
68
  */
60
69
  dismissLabel: string;
70
+ /** Which journey is the latest, so an entrance settling late leaves an exit alone. */
71
+ private turn;
72
+ /** Hidden from a reader once hidden, for the focus hand-off. */
73
+ protected get present(): boolean;
74
+ /**
75
+ * Hides the callout once a dismissal stands, unless the page removed it in
76
+ * its listener, which is the page's answer and leaves `open` as it was.
77
+ */
78
+ dismiss(): boolean;
79
+ protected willUpdate(changed: PropertyValues): void;
80
+ /**
81
+ * Whether the callout is showing. A dismissal nobody canceled turns it
82
+ * false and the callout hides itself, and setting it true shows the callout
83
+ * again. Not reflected: open is the resting state, so an `open` attribute
84
+ * would appear on markup a server sent without it.
85
+ */
86
+ open: boolean;
61
87
  /**
62
88
  * What kind of attention the passage needs.
63
89
  *
@@ -116,6 +142,19 @@ export declare class HelixHeadlessCallout extends HelixHeadlessCallout_base {
116
142
  */
117
143
  private dismissName;
118
144
  protected firstUpdated(changed: PropertyValues): void;
145
+ protected updated(changed: PropertyValues): void;
146
+ /**
147
+ * The way out after a dismissal, or back in after `open` is set again.
148
+ *
149
+ * **Hidden only once the exit has run**, since a hidden element draws no
150
+ * animation: `:state(exiting)` holds until every animation on the callout
151
+ * has finished, or two frames where it has none, and then `hidden` takes it
152
+ * out of the page and out of the accessibility tree. Shown again at once,
153
+ * with `:state(entering)` while it arrives.
154
+ */
155
+ private journey;
156
+ /** Says which regions hold something, one call apiece so each pairs with its `@cssstate`. */
157
+ private readonly readRegions;
119
158
  protected render(): TemplateResult;
120
159
  }
121
160
  declare global {
@@ -4,7 +4,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { customElement, drawDismiss, drawDismissGlyph, formatWords, HelixElement, hostAria, MixinDismissible, slottedWords, TONE_MARKS, toneMark, words, wordsConverter, } from '@fusion.dev/helix-core';
7
+ import { animationsSettled, customElement, drawDismiss, drawDismissGlyph, formatWords, HelixElement, hostAria, MixinDismissible, slottedWords, TONE_MARKS, toneMark, words, wordsConverter, } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
10
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
@@ -12,6 +12,8 @@ let HelixHeadlessCallout = class HelixHeadlessCallout extends MixinDismissible(H
12
12
  constructor() {
13
13
  super(...arguments);
14
14
  this.dismissLabel = 'Dismiss {label}';
15
+ this.turn = 0;
16
+ this.open = true;
15
17
  this.toneLabel = '';
16
18
  this.toneLabels = {
17
19
  danger: 'Error',
@@ -20,14 +22,34 @@ let HelixHeadlessCallout = class HelixHeadlessCallout extends MixinDismissible(H
20
22
  success: 'Success',
21
23
  warning: 'Warning',
22
24
  };
25
+ this.readRegions = () => {
26
+ const filled = (name) => {
27
+ const slot = this.renderRoot?.querySelector(`slot[name="${name}"]`);
28
+ return (slot?.assignedNodes({ flatten: true }) ?? []).some((node) => node.parentNode !== slot &&
29
+ (node.nodeType === Node.ELEMENT_NODE || node.textContent?.trim() !== ''));
30
+ };
31
+ this.toggleState('has-heading', filled('heading'));
32
+ this.toggleState('has-icon', filled('icon'));
33
+ };
23
34
  }
24
35
  static { this.words = 'helix-callout'; }
36
+ get present() {
37
+ return this.isConnected && !this.hasAttribute('hidden');
38
+ }
39
+ dismiss() {
40
+ const stands = super.dismiss();
41
+ if (stands && this.isConnected) {
42
+ this.open = false;
43
+ }
44
+ return stands;
45
+ }
25
46
  willUpdate(changed) {
26
47
  super.willUpdate(changed);
27
48
  hostAria(this.internals, { role: 'note' });
49
+ this.toggleState('closed', !this.open);
28
50
  }
29
51
  renderIcon(icon, _label) {
30
- return html `<slot name="icon" aria-hidden="true">
52
+ return html `<slot name="icon" aria-hidden="true" @slotchange=${this.readRegions}>
31
53
  <svg part="icon-mark" fill="currentColor" viewBox=${icon.viewBox} aria-hidden="true">
32
54
  ${unsafeSVG(icon.content)}
33
55
  </svg>
@@ -48,6 +70,37 @@ let HelixHeadlessCallout = class HelixHeadlessCallout extends MixinDismissible(H
48
70
  super.firstUpdated(changed);
49
71
  this.finishHydration();
50
72
  }
73
+ updated(changed) {
74
+ super.updated(changed);
75
+ this.readRegions();
76
+ if (changed.has('open') && changed.get('open') !== undefined) {
77
+ this.journey();
78
+ }
79
+ else if (changed.has('open') && !this.open) {
80
+ this.setAttribute('hidden', '');
81
+ }
82
+ }
83
+ journey() {
84
+ this.turn += 1;
85
+ const turn = this.turn;
86
+ const open = this.open;
87
+ this.toggleState('entering', open);
88
+ this.toggleState('exiting', !open);
89
+ if (open) {
90
+ this.removeAttribute('hidden');
91
+ }
92
+ void animationsSettled(this).then(() => {
93
+ if (turn !== this.turn) {
94
+ return;
95
+ }
96
+ this.toggleState('entering', false);
97
+ this.toggleState('exiting', false);
98
+ if (!open) {
99
+ this.setAttribute('hidden', '');
100
+ this.handFocusOn();
101
+ }
102
+ });
103
+ }
51
104
  render() {
52
105
  const tone = this.tone === 'neutral' ? 'neutral' : toneMark(this.tone) ? (this.tone ?? 'info') : 'info';
53
106
  const { icon } = toneMark(tone) ?? TONE_MARKS.info;
@@ -69,6 +122,9 @@ __decorate([
69
122
  words,
70
123
  property({ attribute: 'dismiss-label' })
71
124
  ], HelixHeadlessCallout.prototype, "dismissLabel", void 0);
125
+ __decorate([
126
+ property({ type: Boolean })
127
+ ], HelixHeadlessCallout.prototype, "open", void 0);
72
128
  __decorate([
73
129
  property({ reflect: true })
74
130
  ], HelixHeadlessCallout.prototype, "tone", void 0);
@@ -8,11 +8,21 @@
8
8
  * depends on, out of a component library. What is left is punctuation: a header
9
9
  * per file, a `@@` per hunk, and a first character per line.
10
10
  *
11
+ * **Within a changed line it does compare.** A removed line and the added line
12
+ * paired with it are compared word by word, which takes two short strings and
13
+ * no dependency, so a reader sees which words changed without comparing two
14
+ * whole lines by eye.
15
+ *
11
16
  * **Line numbers come from the hunk headers**, so they are the file's numbers
12
17
  * rather than the diff's, which is the only kind a reader can look up. A line
13
18
  * that is not in a hunk is not in the output: file headers, `index` lines and
14
19
  * `diff --git` are metadata, and a name is taken from them and nothing else.
15
20
  */
21
+ /** A run of a changed line's text, and whether the line it is paired with lacks it. */
22
+ export interface DiffPiece {
23
+ changed: boolean;
24
+ text: string;
25
+ }
16
26
  /** One line of a hunk, as the unified format marks it. */
17
27
  export interface DiffLine {
18
28
  kind: 'added' | 'context' | 'removed';
@@ -21,6 +31,11 @@ export interface DiffLine {
21
31
  /** The number on the new side, absent for a line that only the old side has. */
22
32
  new?: number;
23
33
  text: string;
34
+ /**
35
+ * The line's text in runs, where it replaced a line or was replaced by one
36
+ * and the two share some words. Absent for every other line.
37
+ */
38
+ pieces?: DiffPiece[];
24
39
  }
25
40
  /** A `@@` header and the lines under it. */
26
41
  export interface DiffHunk {
@@ -51,6 +66,16 @@ export interface DiffRow {
51
66
  * before it rather than a line of its own, so it is dropped.
52
67
  */
53
68
  export declare const parsePatch: (patch: string) => DiffFile[];
69
+ /**
70
+ * The runs of two paired lines that the other line does not have, by the
71
+ * longest run of words they share in order.
72
+ *
73
+ * **Space between two changes is part of the change**, so "a b" replaced by
74
+ * "c d" is one run, with no gap for the eye to bridge. Two lines
75
+ * that share no word share nothing worth marking: the whole line is the
76
+ * change, which its own wash already says, and `undefined` draws it whole.
77
+ */
78
+ export declare const changedWords: (before: string, after: string) => [DiffPiece[], DiffPiece[]] | undefined;
54
79
  /**
55
80
  * A hunk's lines paired for a side-by-side view.
56
81
  *
@@ -60,3 +85,20 @@ export declare const parsePatch: (patch: string) => DiffFile[];
60
85
  * across rather than hunting for it further down.
61
86
  */
62
87
  export declare const pairLines: (lines: DiffLine[]) => DiffRow[];
88
+ /** A stretch of a hunk's lines: drawn, or unchanged lines folded behind a control. */
89
+ export interface DiffRun {
90
+ /** Whether the lines are unchanged ones folded away. */
91
+ folded: boolean;
92
+ lines: DiffLine[];
93
+ /** Where the run starts among the hunk's lines, which names a fold. */
94
+ start: number;
95
+ }
96
+ /**
97
+ * A hunk's lines with each long run of unchanged lines folded, keeping `keep`
98
+ * lines on the side of each change, as a code host draws a diff with more
99
+ * context than a reader needs.
100
+ *
101
+ * A fold hides two lines or more: a control in place of one line is no
102
+ * shorter than the line.
103
+ */
104
+ export declare const foldContext: (lines: DiffLine[], keep: number) => DiffRun[];
@@ -63,8 +63,79 @@ export const parsePatch = (patch) => {
63
63
  }
64
64
  }
65
65
  }
66
+ for (const one of files) {
67
+ for (const { lines } of one.hunks) {
68
+ markWords(lines);
69
+ }
70
+ }
66
71
  return files;
67
72
  };
73
+ const TOKEN = /[\p{L}\p{N}_]+|\s+|[^\p{L}\p{N}_\s]/gu;
74
+ const MOST_TOKENS = 200;
75
+ const joined = (pieces) => {
76
+ const out = [];
77
+ for (const piece of pieces) {
78
+ const last = out.at(-1);
79
+ if (last && last.changed === piece.changed) {
80
+ last.text += piece.text;
81
+ }
82
+ else {
83
+ out.push({ ...piece });
84
+ }
85
+ }
86
+ return out;
87
+ };
88
+ export const changedWords = (before, after) => {
89
+ const a = before.match(TOKEN) ?? [];
90
+ const b = after.match(TOKEN) ?? [];
91
+ if (a.length === 0 || b.length === 0 || a.length > MOST_TOKENS || b.length > MOST_TOKENS) {
92
+ return undefined;
93
+ }
94
+ const width = b.length + 1;
95
+ const table = new Uint16Array((a.length + 1) * width);
96
+ for (let i = a.length - 1; i >= 0; i -= 1) {
97
+ for (let j = b.length - 1; j >= 0; j -= 1) {
98
+ table[i * width + j] =
99
+ a[i] === b[j]
100
+ ? table[(i + 1) * width + j + 1] + 1
101
+ : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
102
+ }
103
+ }
104
+ const left = [];
105
+ const right = [];
106
+ let shared = false;
107
+ let i = 0;
108
+ let j = 0;
109
+ while (i < a.length || j < b.length) {
110
+ if (i < a.length && j < b.length && a[i] === b[j]) {
111
+ shared ||= a[i].trim() !== '';
112
+ left.push({ changed: false, text: a[i] });
113
+ right.push({ changed: false, text: b[j] });
114
+ i += 1;
115
+ j += 1;
116
+ }
117
+ else if (j >= b.length ||
118
+ (i < a.length &&
119
+ table[(i + 1) * width + j] >= table[i * width + j + 1])) {
120
+ left.push({ changed: true, text: a[i] });
121
+ i += 1;
122
+ }
123
+ else {
124
+ right.push({ changed: true, text: b[j] });
125
+ j += 1;
126
+ }
127
+ }
128
+ if (!shared) {
129
+ return undefined;
130
+ }
131
+ const bridged = (pieces) => joined(pieces.map((piece, at) => !piece.changed &&
132
+ piece.text.trim() === '' &&
133
+ pieces[at - 1]?.changed &&
134
+ pieces[at + 1]?.changed
135
+ ? { ...piece, changed: true }
136
+ : piece));
137
+ return [bridged(left), bridged(right)];
138
+ };
68
139
  export const pairLines = (lines) => {
69
140
  const rows = [];
70
141
  let at = 0;
@@ -91,3 +162,50 @@ export const pairLines = (lines) => {
91
162
  }
92
163
  return rows;
93
164
  };
165
+ const markWords = (lines) => {
166
+ for (const { left, right } of pairLines(lines)) {
167
+ if (left?.kind !== 'removed' || right?.kind !== 'added') {
168
+ continue;
169
+ }
170
+ const pieces = changedWords(left.text, right.text);
171
+ if (pieces) {
172
+ ;
173
+ [left.pieces, right.pieces] = pieces;
174
+ }
175
+ }
176
+ };
177
+ export const foldContext = (lines, keep) => {
178
+ const runs = [];
179
+ const add = (start, end, folded) => {
180
+ if (end <= start) {
181
+ return;
182
+ }
183
+ const last = runs.at(-1);
184
+ if (last && !last.folded && !folded) {
185
+ last.lines.push(...lines.slice(start, end));
186
+ }
187
+ else {
188
+ runs.push({ folded, lines: lines.slice(start, end), start });
189
+ }
190
+ };
191
+ let at = 0;
192
+ while (at < lines.length) {
193
+ const context = lines[at]?.kind === 'context';
194
+ let end = at;
195
+ while (end < lines.length && (lines[end]?.kind === 'context') === context) {
196
+ end += 1;
197
+ }
198
+ const head = context && at > 0 ? keep : 0;
199
+ const tail = context && end < lines.length ? keep : 0;
200
+ if (context && end - at - head - tail >= 2) {
201
+ add(at, at + head, false);
202
+ add(at + head, end - tail, true);
203
+ add(end - tail, end, false);
204
+ }
205
+ else {
206
+ add(at, end, false);
207
+ }
208
+ at = end;
209
+ }
210
+ return runs;
211
+ };
@@ -45,9 +45,14 @@ import '../../copy-button/index.js';
45
45
  * removed. The block never computes a diff itself. Line numbers come from the
46
46
  * hunk headers and are hidden from the accessibility tree. Changed lines are
47
47
  * `<ins>` and `<del>` with the `+` and `-` kept as text, so the change does not
48
- * depend on color. Each file in the patch is its own block under its own name,
48
+ * depend on color. Where a removed line is paired with the added line that
49
+ * replaced it, the words each does not share with the other are the `changed`
50
+ * part, so the eye goes to what changed within the line. Each file in the patch is its own block under its own name,
49
51
  * and its copy button copies the new version of the file. `variant="split"`
50
52
  * shows the old version beside the new, each removal opposite its replacement.
53
+ * `context-lines` folds each long run of unchanged lines, keeping that many
54
+ * beside each change, behind a button that shows them and hides them again,
55
+ * as a code host draws a diff written with more context than a reader needs.
51
56
  *
52
57
  * **A block that scrolls is a tab stop.** While its lines run past its width or
53
58
  * its cap, the scroller is a region named for the block, such as "Code,
@@ -83,6 +88,7 @@ import '../../copy-button/index.js';
83
88
  * @csspart actions - The box in the header that holds the `actions` slot, drawn while something is slotted there.
84
89
  * @csspart added - In a diff, a line only the new version has, an `<ins>`.
85
90
  * @csspart block - The scrolling `<pre>`.
91
+ * @csspart changed - In a diff, the words of a changed line that the line opposite it does not have, inside its `text`.
86
92
  * @csspart code - The `<code>` inside it.
87
93
  * @csspart context - In a diff, a line both versions share.
88
94
  * @csspart copy - The copy button. It sits in the header when there is one, and beside the code otherwise.
@@ -104,6 +110,7 @@ import '../../copy-button/index.js';
104
110
  * @csspart expand - The button under a capped block that shows every line, and fewer again. Drawn when `expandable` is set and the cap hides something.
105
111
  * @csspart file - In a diff, one file's header and block.
106
112
  * @csspart filler - In a split diff, the empty row opposite a line the other side does not have.
113
+ * @csspart fold - In a diff with `context-lines`, the button that shows a folded run of unchanged lines, and hides it again, saying which with `aria-expanded`.
107
114
  * @csspart frame - The code, and the copy button beside it where there is no header.
108
115
  * @csspart gutter - The line numbers beside the code, shown when `line-numbers` is set.
109
116
  * @csspart header - The bar that holds the name and the copy button, shown when there is a name.
@@ -148,6 +155,12 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
148
155
  * The expand button's words while the block shows every line.
149
156
  */
150
157
  collapseLabel: string;
158
+ /**
159
+ * In a diff, how many unchanged lines to keep beside each change. Each
160
+ * longer run of unchanged lines is folded behind a button that shows it.
161
+ * Unset shows every line the diff holds.
162
+ */
163
+ contextLines?: number;
151
164
  /**
152
165
  * The copy button's name and tooltip text after a copy.
153
166
  */
@@ -185,6 +198,12 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
185
198
  * The expand button's words while the cap hides lines.
186
199
  */
187
200
  expandLabel: string;
201
+ /**
202
+ * The words of a button that hides a run of unchanged lines it showed, in a
203
+ * diff with `context-lines`. `{count}` is how many, and each language
204
+ * writes the forms it has.
205
+ */
206
+ hideUnchangedLabel: string;
188
207
  /**
189
208
  * Turns the code into highlighted markup, which the block draws in place of
190
209
  * the plain text.
@@ -263,6 +282,11 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
263
282
  * `false`.
264
283
  */
265
284
  noCopy: boolean;
285
+ /**
286
+ * The words of a button that shows a folded run of unchanged lines, in a
287
+ * diff with `context-lines`. `{count}` is how many.
288
+ */
289
+ showUnchangedLabel: string;
266
290
  /**
267
291
  * How a diff's two versions are arranged. It has no effect without `diff`.
268
292
  *
@@ -295,6 +319,18 @@ export declare class HelixHeadlessCodeBlock extends HelixElement {
295
319
  private parsed;
296
320
  /** The files in the content, when it is a diff and it can be read yet. */
297
321
  private get files();
322
+ /** The folds a reader opened, by file, hunk and the line each starts at. */
323
+ private readonly unfolded;
324
+ /** A hunk's lines in runs, folded where `context-lines` asks. */
325
+ private runsOf;
326
+ /**
327
+ * The button over a folded run, and the run once it is open.
328
+ *
329
+ * A disclosure, so focus stays on the button as the lines appear under it.
330
+ * The new side of a split diff draws the same words hidden from the
331
+ * accessibility tree, there to keep the rows level and to answer a press.
332
+ */
333
+ private renderFold;
298
334
  /**
299
335
  * The code as text, read from what the default slot is given rather than
300
336
  * stored.
@@ -12,7 +12,7 @@ import { html } from 'lit/static-html.js';
12
12
  import '../../copy-button/index.js';
13
13
  import { highlightedLines } from '../highlight.js';
14
14
  import { gutterNumbers, lineCount, lineRuns, lineSpans } from '../lines.js';
15
- import { pairLines, parsePatch } from '../patch.js';
15
+ import { foldContext, pairLines, parsePatch, } from '../patch.js';
16
16
  let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
17
17
  constructor() {
18
18
  super(...arguments);
@@ -25,15 +25,18 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
25
25
  this.diff = false;
26
26
  this.expandable = false;
27
27
  this.expandLabel = 'Show all lines';
28
+ this.hideUnchangedLabel = '{count, plural, one {Hide # unchanged line} other {Hide # unchanged lines}}';
28
29
  this.label = '';
29
30
  this.language = '';
30
31
  this.lineNumbers = false;
31
32
  this.markLines = '';
32
33
  this.noCopy = false;
34
+ this.showUnchangedLabel = '{count, plural, one {Show # unchanged line} other {Show # unchanged lines}}';
33
35
  this.wrap = false;
34
36
  this.wrapLabel = 'Wrap lines';
35
37
  this.wrappable = false;
36
38
  this.parsed = { files: [], text: '' };
39
+ this.unfolded = new Set();
37
40
  this.capped = false;
38
41
  this.expanded = false;
39
42
  this.measure = () => {
@@ -69,9 +72,32 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
69
72
  const text = this.text;
70
73
  if (text !== this.parsed.text) {
71
74
  this.parsed = { files: parsePatch(text), text };
75
+ this.unfolded.clear();
72
76
  }
73
77
  return this.parsed.files;
74
78
  }
79
+ runsOf(hunk) {
80
+ const keep = this.contextLines;
81
+ return keep === undefined || keep < 0
82
+ ? [{ folded: false, lines: hunk.lines, start: 0 }]
83
+ : foldContext(hunk.lines, Math.floor(keep));
84
+ }
85
+ renderFold(run, key, line, hidden = false) {
86
+ const open = this.unfolded.has(key);
87
+ const words = formatWords(this, open ? 'hideUnchangedLabel' : 'showUnchangedLabel', {
88
+ count: run.lines.length,
89
+ });
90
+ const toggle = () => {
91
+ if (!this.unfolded.delete(key)) {
92
+ this.unfolded.add(key);
93
+ }
94
+ this.requestUpdate();
95
+ };
96
+ const control = hidden
97
+ ? html `<span part="fold" aria-hidden="true" @click=${toggle}>${words}</span>`
98
+ : html `<button part="fold" type="button" aria-expanded=${open ? 'true' : 'false'} @click=${toggle}>${words}</button>`;
99
+ return html `${control}\n${open ? run.lines.map(line) : nothing}`;
100
+ }
75
101
  get text() {
76
102
  if (this.hydrating) {
77
103
  return '';
@@ -319,7 +345,10 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
319
345
  const numbers = side
320
346
  ? this.renderNumber(side === 'old' ? line.old : line.new, width)
321
347
  : html `${this.renderNumber(line.old, width)}${this.renderNumber(line.new, width)}`;
322
- const text = html `<span part="text">${line.text}\n</span>`;
348
+ const words = line.pieces
349
+ ? line.pieces.map((piece) => piece.changed ? html `<span part="changed">${piece.text}</span>` : piece.text)
350
+ : line.text;
351
+ const text = html `<span part="text">${words}\n</span>`;
323
352
  if (line.kind === 'added') {
324
353
  return html `<ins part="added">${numbers}<span part="marker">+</span>${text}</ins>`;
325
354
  }
@@ -331,16 +360,22 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
331
360
  renderHunk(hunk, hidden = false) {
332
361
  return html `<span part="hunk" aria-hidden=${hidden ? 'true' : nothing}>${hunk.header}\n</span>`;
333
362
  }
334
- renderSplit(file, width) {
335
- const hunks = file.hunks.map((hunk) => ({ hunk, rows: pairLines(hunk.lines) }));
363
+ renderSplit(file, width, at) {
364
+ const hunks = file.hunks.map((hunk) => ({ hunk, runs: this.runsOf(hunk) }));
336
365
  const filler = html `<span part="filler" aria-hidden="true">\n</span>`;
366
+ const side = (old) => hunks.map(({ hunk, runs }, h) => html `${this.renderHunk(hunk, !old)}${runs.map((run) => run.folded
367
+ ? this.renderFold(run, `${at}:${h}:${run.start}`, (line) => this.renderLine(line, width, old ? 'old' : 'new'), !old)
368
+ : pairLines(run.lines).map(({ left, right }) => {
369
+ const line = old ? left : right;
370
+ return line ? this.renderLine(line, width, old ? 'old' : 'new') : filler;
371
+ }))}`);
337
372
  return html `<div part="split">
338
- <pre part="old" translate="no"><code part="code">${hunks.map(({ hunk, rows }) => html `${this.renderHunk(hunk)}${rows.map(({ left }) => left ? this.renderLine(left, width, 'old') : filler)}`)}</code></pre>
339
- <pre part="new" translate="no"><code part="code">${hunks.map(({ hunk, rows }) => html `${this.renderHunk(hunk, true)}${rows.map(({ right }) => right ? this.renderLine(right, width, 'new') : filler)}`)}</code></pre>
373
+ <pre part="old" translate="no"><code part="code">${side(true)}</code></pre>
374
+ <pre part="new" translate="no"><code part="code">${side(false)}</code></pre>
340
375
  </div>`;
341
376
  }
342
377
  renderDiff(files) {
343
- return html `${files.map((file) => {
378
+ return html `${files.map((file, at) => {
344
379
  const name = file.name || this.label;
345
380
  const widest = Math.max(0, ...file.hunks.flatMap((hunk) => hunk.lines.map((line) => Math.max(line.old ?? 0, line.new ?? 0))));
346
381
  const width = String(widest).length;
@@ -352,8 +387,10 @@ let HelixHeadlessCodeBlock = class HelixHeadlessCodeBlock extends HelixElement {
352
387
  ${this.renderHeader(name, now)}
353
388
  <div part="frame">
354
389
  ${this.variant === 'split'
355
- ? this.renderSplit(file, width)
356
- : html `<pre part="block" translate="no"><code part="code">${file.hunks.map((hunk) => html `${this.renderHunk(hunk)}${hunk.lines.map((line) => this.renderLine(line, width))}`)}</code></pre>`}
390
+ ? this.renderSplit(file, width, at)
391
+ : html `<pre part="block" translate="no"><code part="code">${file.hunks.map((hunk, h) => html `${this.renderHunk(hunk)}${this.runsOf(hunk).map((run) => run.folded
392
+ ? this.renderFold(run, `${at}:${h}:${run.start}`, (line) => this.renderLine(line, width))
393
+ : run.lines.map((line) => this.renderLine(line, width)))}`)}</code></pre>`}
357
394
  ${this.headed(name) ? nothing : this.renderCopy(now)}
358
395
  </div>
359
396
  </div>`;
@@ -372,6 +409,9 @@ __decorate([
372
409
  words,
373
410
  property({ attribute: 'collapse-label' })
374
411
  ], HelixHeadlessCodeBlock.prototype, "collapseLabel", void 0);
412
+ __decorate([
413
+ property({ attribute: 'context-lines', type: Number })
414
+ ], HelixHeadlessCodeBlock.prototype, "contextLines", void 0);
375
415
  __decorate([
376
416
  words,
377
417
  property({ attribute: 'copied-label' })
@@ -394,6 +434,10 @@ __decorate([
394
434
  words,
395
435
  property({ attribute: 'expand-label' })
396
436
  ], HelixHeadlessCodeBlock.prototype, "expandLabel", void 0);
437
+ __decorate([
438
+ words,
439
+ property({ attribute: 'hide-unchanged-label' })
440
+ ], HelixHeadlessCodeBlock.prototype, "hideUnchangedLabel", void 0);
397
441
  __decorate([
398
442
  property({ attribute: false })
399
443
  ], HelixHeadlessCodeBlock.prototype, "highlight", void 0);
@@ -412,6 +456,10 @@ __decorate([
412
456
  __decorate([
413
457
  property({ attribute: 'no-copy', reflect: true, type: Boolean })
414
458
  ], HelixHeadlessCodeBlock.prototype, "noCopy", void 0);
459
+ __decorate([
460
+ words,
461
+ property({ attribute: 'show-unchanged-label' })
462
+ ], HelixHeadlessCodeBlock.prototype, "showUnchangedLabel", void 0);
415
463
  __decorate([
416
464
  property({ reflect: true })
417
465
  ], HelixHeadlessCodeBlock.prototype, "variant", void 0);
@@ -22,8 +22,10 @@ declare const HelixHeadlessColorPicker_base: (new (...args: any[]) => import("@f
22
22
  * gray after you drag to the bottom of the square and back.
23
23
  *
24
24
  * The square is one tab stop. In it, the up and down arrows move brightness
25
- * and the left and right arrows move saturation, and PageUp and PageDown move
26
- * brightness by ten. The picker sends `input` as the color moves and `change`
25
+ * and the left and right arrows move saturation, by ten with Shift. PageUp and
26
+ * PageDown move brightness by ten, and Home and End move saturation by ten,
27
+ * turned on a right-to-left page as the arrows are, as React Aria's ColorArea
28
+ * does. The picker sends `input` as the color moves and `change`
27
29
  * when a drag, a key, a typed value or a preset settles it, as
28
30
  * `<input type="color">` does.
29
31
  *
@@ -546,7 +548,9 @@ export declare class HelixHeadlessColorPicker extends HelixHeadlessColorPicker_b
546
548
  * its own one axis: on the saturation input, ArrowUp moved the handle
547
549
  * sideways. Here the vertical keys move brightness and the horizontal keys
548
550
  * move saturation, turned on a right-to-left page as the pointer is, which is
549
- * what React Aria's ColorArea does.
551
+ * what React Aria's ColorArea does. Shift makes an arrow a step of ten, and
552
+ * Home and End are saturation's steps of ten, as PageUp and PageDown are
553
+ * brightness's.
550
554
  */
551
555
  private readonly handleAreaKeydown;
552
556
  /**
@@ -211,11 +211,14 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
211
211
  const color = this.color;
212
212
  const along = isRtl(this) ? -1 : 1;
213
213
  const held = (at) => clamp(at, 0, 100);
214
+ const step = event.shiftKey ? 10 : 1;
214
215
  const moves = {
215
- ArrowDown: { brightness: held(color.brightness - 1) },
216
- ArrowLeft: { saturation: held(color.saturation - along) },
217
- ArrowRight: { saturation: held(color.saturation + along) },
218
- ArrowUp: { brightness: held(color.brightness + 1) },
216
+ ArrowDown: { brightness: held(color.brightness - step) },
217
+ ArrowLeft: { saturation: held(color.saturation - along * step) },
218
+ ArrowRight: { saturation: held(color.saturation + along * step) },
219
+ ArrowUp: { brightness: held(color.brightness + step) },
220
+ End: { saturation: held(color.saturation + along * 10) },
221
+ Home: { saturation: held(color.saturation - along * 10) },
219
222
  PageDown: { brightness: held(color.brightness - 10) },
220
223
  PageUp: { brightness: held(color.brightness + 10) },
221
224
  };