@gjermundgaraba-designs/pixel-paper 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.
- package/DESIGN.md +11 -11
- package/dist/src/emptyState/emptyState.d.ts +4 -0
- package/dist/src/emptyState/emptyState.d.ts.map +1 -1
- package/dist/src/emptyState/emptyState.js +4 -2
- package/dist/src/field/field.d.ts +14 -6
- package/dist/src/field/field.d.ts.map +1 -1
- package/dist/src/field/field.js +17 -5
- package/dist/src/fieldset/fieldset.d.ts +7 -2
- package/dist/src/fieldset/fieldset.d.ts.map +1 -1
- package/dist/src/fieldset/fieldset.js +10 -4
- package/dist/src/lineDiff/diff.d.ts +26 -31
- package/dist/src/lineDiff/diff.d.ts.map +1 -1
- package/dist/src/lineDiff/diff.js +62 -41
- package/dist/src/lineDiff/index.d.ts +2 -2
- package/dist/src/lineDiff/index.d.ts.map +1 -1
- package/dist/src/lineDiff/index.js +1 -1
- package/dist/src/lineDiff/lineDiff.d.ts +14 -0
- package/dist/src/lineDiff/lineDiff.d.ts.map +1 -1
- package/dist/src/lineDiff/lineDiff.js +43 -21
- package/dist/src/passwordField/passwordField.d.ts +11 -6
- package/dist/src/passwordField/passwordField.d.ts.map +1 -1
- package/dist/src/passwordField/passwordField.js +10 -7
- package/dist/src/radioChoices/radioChoices.d.ts +4 -4
- package/dist/src/radioChoices/radioChoices.d.ts.map +1 -1
- package/dist/src/rail/rail.d.ts +2 -2
- package/dist/src/rail/rail.d.ts.map +1 -1
- package/dist/src/rail/rail.js +5 -3
- package/dist/src/rows/rows.d.ts +7 -2
- package/dist/src/rows/rows.d.ts.map +1 -1
- package/dist/src/rows/rows.js +3 -3
- package/dist/src/selectField/selectField.d.ts +7 -5
- package/dist/src/selectField/selectField.d.ts.map +1 -1
- package/dist/src/selectField/selectField.js +12 -6
- package/dist/src/sheet/sheet.d.ts +3 -2
- package/dist/src/sheet/sheet.d.ts.map +1 -1
- package/dist/src/sheet/sheet.js +3 -2
- package/dist/src/slot/slot.d.ts +2 -1
- package/dist/src/slot/slot.d.ts.map +1 -1
- package/dist/src/slot/slot.js +2 -2
- package/dist/src/textareaField/textareaField.d.ts +15 -5
- package/dist/src/textareaField/textareaField.d.ts.map +1 -1
- package/dist/src/textareaField/textareaField.js +15 -6
- package/package.json +1 -1
- package/src/emptyState/emptyState.ts +8 -2
- package/src/field/field.ts +37 -8
- package/src/fieldset/fieldset.ts +16 -4
- package/src/lineDiff/diff.ts +104 -48
- package/src/lineDiff/index.ts +2 -2
- package/src/lineDiff/lineDiff.ts +76 -31
- package/src/passwordField/passwordField.ts +18 -9
- package/src/pixel-paper.css +26 -5
- package/src/radioChoices/radioChoices.ts +2 -2
- package/src/rail/rail.ts +5 -3
- package/src/rows/rows.ts +9 -4
- package/src/selectField/selectField.ts +17 -17
- package/src/sheet/sheet.ts +3 -2
- package/src/slot/slot.ts +4 -1
- package/src/textareaField/textareaField.ts +39 -9
package/src/field/field.ts
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
import { Input as UiInput } from "@foldkit/ui";
|
|
2
|
-
import type { Html, HtmlBuilder } from "foldkit/html";
|
|
2
|
+
import type { Html, HtmlBuilder, TextareaAttribute } from "foldkit/html";
|
|
3
3
|
|
|
4
4
|
import type { Attributes } from "../attributes/attributes.ts";
|
|
5
5
|
|
|
6
|
+
// ERROR
|
|
7
|
+
|
|
8
|
+
const errorId = (id: string): string => `${id}-error`;
|
|
9
|
+
|
|
10
|
+
/** The red sentence under a field's control that says why it failed, given an error. */
|
|
11
|
+
export const errorView = <Message>(
|
|
12
|
+
id: string,
|
|
13
|
+
error: string | undefined,
|
|
14
|
+
h: HtmlBuilder<Message>,
|
|
15
|
+
): ReadonlyArray<Html> =>
|
|
16
|
+
error === undefined ? [] : [h.p([h.Id(errorId(id)), h.Class("field-error")], [error])];
|
|
17
|
+
|
|
18
|
+
/** Points the control at its error, then at its help line when it has one. After the
|
|
19
|
+
* `@foldkit/ui` attributes, whose `aria-describedby` names the help line alone; every
|
|
20
|
+
* `@foldkit/ui` control names that line `<id>-description`, as Input does. */
|
|
21
|
+
export const describedByError = <Message>(
|
|
22
|
+
id: string,
|
|
23
|
+
error: string | undefined,
|
|
24
|
+
hasHelp: boolean,
|
|
25
|
+
h: HtmlBuilder<Message>,
|
|
26
|
+
): ReadonlyArray<TextareaAttribute<Message>> =>
|
|
27
|
+
error === undefined
|
|
28
|
+
? []
|
|
29
|
+
: [h.AriaDescribedBy(hasHelp ? `${errorId(id)} ${UiInput.descriptionId(id)}` : errorId(id))];
|
|
30
|
+
|
|
6
31
|
// VIEW
|
|
7
32
|
|
|
8
33
|
/** Configuration for rendering a text field with `view`. */
|
|
@@ -13,8 +38,10 @@ export type ViewConfig<Message> = Readonly<{
|
|
|
13
38
|
onInput: (value: string) => Message;
|
|
14
39
|
/** A sans sentence under the control. */
|
|
15
40
|
help?: string;
|
|
16
|
-
/**
|
|
17
|
-
|
|
41
|
+
/** Why the value was refused: a red sentence under the control, which the control is
|
|
42
|
+
* described by, with red on its rule and its label (`aria-invalid`). Say it again in an
|
|
43
|
+
* ErrorSummary above the form. */
|
|
44
|
+
error?: string | undefined;
|
|
18
45
|
/** The form is not sent without it: screen readers hear "required" (`aria-required`). */
|
|
19
46
|
isRequired?: boolean;
|
|
20
47
|
/** A 2px ink box: the field that is the whole job of its form. A date is always boxed. */
|
|
@@ -30,9 +57,9 @@ export type ViewConfig<Message> = Readonly<{
|
|
|
30
57
|
}>;
|
|
31
58
|
|
|
32
59
|
/** A labelled text input with its help line, built on `@foldkit/ui`'s `Input`: the label
|
|
33
|
-
* names the input, the help line describes it (`aria-describedby`), `
|
|
34
|
-
* `aria-invalid` and `isRequired` sets `aria-required`.
|
|
35
|
-
* baseline that turns solid ink on focus. */
|
|
60
|
+
* names the input, the help line describes it (`aria-describedby`), an `error` sets
|
|
61
|
+
* `aria-invalid` and describes it before the help, and `isRequired` sets `aria-required`.
|
|
62
|
+
* The input is boxless, a dotted baseline that turns solid ink on focus. */
|
|
36
63
|
export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Message>): Html => {
|
|
37
64
|
const {
|
|
38
65
|
id,
|
|
@@ -40,7 +67,7 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
40
67
|
value,
|
|
41
68
|
onInput,
|
|
42
69
|
help,
|
|
43
|
-
|
|
70
|
+
error,
|
|
44
71
|
isRequired = false,
|
|
45
72
|
isBoxed = false,
|
|
46
73
|
isMono = false,
|
|
@@ -53,7 +80,7 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
53
80
|
id,
|
|
54
81
|
value,
|
|
55
82
|
onInput,
|
|
56
|
-
isInvalid,
|
|
83
|
+
isInvalid: error !== undefined,
|
|
57
84
|
...(type !== undefined && { type }),
|
|
58
85
|
hasDescription: help !== undefined,
|
|
59
86
|
toView: (groups) =>
|
|
@@ -65,8 +92,10 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
65
92
|
h.input([
|
|
66
93
|
...inputAttributes,
|
|
67
94
|
...groups.input,
|
|
95
|
+
...describedByError(id, error, help !== undefined, h),
|
|
68
96
|
...(isRequired ? [h.AriaRequired(true)] : []),
|
|
69
97
|
]),
|
|
98
|
+
...errorView(id, error, h),
|
|
70
99
|
...(help === undefined
|
|
71
100
|
? []
|
|
72
101
|
: [h.p([...groups.description, h.Class("field-help")], [help])]),
|
package/src/fieldset/fieldset.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Fieldset as UiFieldset } from "@foldkit/ui";
|
|
2
2
|
import type { Html, HtmlBuilder } from "foldkit/html";
|
|
3
3
|
|
|
4
|
+
import { describedByError, errorView } from "../field/field.ts";
|
|
5
|
+
|
|
4
6
|
// VIEW
|
|
5
7
|
|
|
6
8
|
/** Configuration for rendering a fieldset with `view`. */
|
|
@@ -11,15 +13,20 @@ export type ViewConfig = Readonly<{
|
|
|
11
13
|
label: string;
|
|
12
14
|
/** A sans sentence under the controls; it describes the group. */
|
|
13
15
|
help?: string;
|
|
16
|
+
/** Why the choice was refused: a red sentence under the controls, which the group is
|
|
17
|
+
* described by, before its help. Mark the control itself invalid too (RadioChoices'
|
|
18
|
+
* `isInvalid`), which turns the legend red. Say it again in an ErrorSummary above the
|
|
19
|
+
* form. */
|
|
20
|
+
error?: string | undefined;
|
|
14
21
|
/** The controls: Choices, a Segmented. */
|
|
15
22
|
body: ReadonlyArray<Html | string>;
|
|
16
23
|
}>;
|
|
17
24
|
|
|
18
25
|
/** A group of controls under one name, built on `@foldkit/ui`'s Fieldset: a `<fieldset>`
|
|
19
|
-
* named by its `<legend>` and described by its
|
|
20
|
-
* turns red while a control in it is invalid. */
|
|
26
|
+
* named by its `<legend>` and described by its error and its help line
|
|
27
|
+
* (`aria-describedby`). The legend turns red while a control in it is invalid. */
|
|
21
28
|
export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html => {
|
|
22
|
-
const { id, label, help, body } = config;
|
|
29
|
+
const { id, label, help, error, body } = config;
|
|
23
30
|
|
|
24
31
|
return UiFieldset.view(
|
|
25
32
|
{
|
|
@@ -27,10 +34,15 @@ export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html
|
|
|
27
34
|
hasDescription: help !== undefined,
|
|
28
35
|
toView: (groups) =>
|
|
29
36
|
h.fieldset(
|
|
30
|
-
[
|
|
37
|
+
[
|
|
38
|
+
...groups.fieldset,
|
|
39
|
+
...describedByError(id, error, help !== undefined, h),
|
|
40
|
+
h.Class("field"),
|
|
41
|
+
],
|
|
31
42
|
[
|
|
32
43
|
h.legend([...groups.legend, h.Class("field-label")], [label]),
|
|
33
44
|
...body,
|
|
45
|
+
...errorView(id, error, h),
|
|
34
46
|
...(help === undefined
|
|
35
47
|
? []
|
|
36
48
|
: [h.p([...groups.description, h.Class("field-help")], [help])]),
|
package/src/lineDiff/diff.ts
CHANGED
|
@@ -1,30 +1,24 @@
|
|
|
1
|
-
import { diffLines } from "diff";
|
|
1
|
+
import { type StructuredPatchHunk, diffLines, parsePatch } from "diff";
|
|
2
2
|
import { Array, Match, Option, Schema, pipe } from "effect";
|
|
3
3
|
import { defineTaggedUnion } from "foldkit/schema";
|
|
4
4
|
|
|
5
|
-
//
|
|
5
|
+
// ROWS
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
export type LineKind = typeof LineKind.Type;
|
|
10
|
-
|
|
11
|
-
/** One line of a diff, numbered on the side or sides it is on. */
|
|
12
|
-
export const DiffLine = Schema.Struct({
|
|
13
|
-
kind: LineKind,
|
|
14
|
-
text: Schema.String,
|
|
15
|
-
maybeOldNumber: Schema.Option(Schema.Number),
|
|
16
|
-
maybeNewNumber: Schema.Option(Schema.Number),
|
|
17
|
-
});
|
|
18
|
-
export type DiffLine = typeof DiffLine.Type;
|
|
19
|
-
|
|
20
|
-
/** A row of a folded diff: a line, or a run of two or more unchanged lines folded into a
|
|
21
|
-
* count, with the run's first line. */
|
|
7
|
+
/** A row of a diff: a line, numbered on the side or sides it is on, or a run of unchanged
|
|
8
|
+
* lines folded into a count, known by its first line's old number. */
|
|
22
9
|
export const DiffRow = defineTaggedUnion({
|
|
23
|
-
|
|
24
|
-
|
|
10
|
+
Same: { text: Schema.String, oldNumber: Schema.Number, newNumber: Schema.Number },
|
|
11
|
+
Added: { text: Schema.String, newNumber: Schema.Number },
|
|
12
|
+
Removed: { text: Schema.String, oldNumber: Schema.Number },
|
|
13
|
+
Fold: { count: Schema.Number, firstOldNumber: Schema.Number },
|
|
25
14
|
});
|
|
26
15
|
export type DiffRow = typeof DiffRow.Type;
|
|
27
16
|
|
|
17
|
+
type SameRow = Extract<DiffRow, Readonly<{ _tag: "Same" }>>;
|
|
18
|
+
|
|
19
|
+
/** What a line did between the two sides. */
|
|
20
|
+
export type LineKind = "Same" | "Added" | "Removed";
|
|
21
|
+
|
|
28
22
|
type Numbers = Readonly<{ old: number; new: number }>;
|
|
29
23
|
|
|
30
24
|
const withFinalNewline = (text: string): string => (text.endsWith("\n") ? text : `${text}\n`);
|
|
@@ -37,24 +31,27 @@ const changeKind = (change: Readonly<{ added: boolean; removed: boolean }>): Lin
|
|
|
37
31
|
Match.orElse(() => "Same"),
|
|
38
32
|
);
|
|
39
33
|
|
|
40
|
-
const numberLine = (numbers: Numbers, kind: LineKind, text: string): [Numbers,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
34
|
+
const numberLine = (numbers: Numbers, kind: LineKind, text: string): [Numbers, DiffRow] =>
|
|
35
|
+
Match.value(kind).pipe(
|
|
36
|
+
Match.withReturnType<[Numbers, DiffRow]>(),
|
|
37
|
+
Match.when("Same", () => [
|
|
38
|
+
{ old: numbers.old + 1, new: numbers.new + 1 },
|
|
39
|
+
DiffRow.Same({ text, oldNumber: numbers.old, newNumber: numbers.new }),
|
|
40
|
+
]),
|
|
41
|
+
Match.when("Added", () => [
|
|
42
|
+
{ old: numbers.old, new: numbers.new + 1 },
|
|
43
|
+
DiffRow.Added({ text, newNumber: numbers.new }),
|
|
44
|
+
]),
|
|
45
|
+
Match.when("Removed", () => [
|
|
46
|
+
{ old: numbers.old + 1, new: numbers.new },
|
|
47
|
+
DiffRow.Removed({ text, oldNumber: numbers.old }),
|
|
48
|
+
]),
|
|
49
|
+
Match.exhaustive,
|
|
50
|
+
);
|
|
54
51
|
|
|
55
52
|
/** The line diff of two texts (Myers, through jsdiff), each line numbered on its side. A
|
|
56
53
|
* missing final newline is not a change. */
|
|
57
|
-
export const
|
|
54
|
+
export const diffRowsOf = (before: string, after: string): ReadonlyArray<DiffRow> =>
|
|
58
55
|
pipe(
|
|
59
56
|
diffLines(withFinalNewline(before), withFinalNewline(after)),
|
|
60
57
|
Array.flatMap((change) =>
|
|
@@ -67,23 +64,82 @@ export const diffLinesOf = (before: string, after: string): ReadonlyArray<DiffLi
|
|
|
67
64
|
Array.mapAccum(lines, { old: 1, new: 1 }, (numbers, { kind, text }) =>
|
|
68
65
|
numberLine(numbers, kind, text),
|
|
69
66
|
),
|
|
70
|
-
([,
|
|
67
|
+
([, rows]) => rows,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/** Whether a line was added or removed. */
|
|
71
|
+
export const hasChanges = (rows: ReadonlyArray<DiffRow>): boolean =>
|
|
72
|
+
Array.some(rows, (row) => row._tag === "Added" || row._tag === "Removed");
|
|
73
|
+
|
|
74
|
+
// PATCH
|
|
75
|
+
|
|
76
|
+
type PatchState = Readonly<{ rows: ReadonlyArray<DiffRow>; numbers: Numbers }>;
|
|
77
|
+
|
|
78
|
+
const LINE_KIND_BY_PREFIX: Readonly<Record<string, LineKind>> = {
|
|
79
|
+
" ": "Same",
|
|
80
|
+
"+": "Added",
|
|
81
|
+
"-": "Removed",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// a blank context line that lost its leading space on the way (a trimming editor, a mail) is
|
|
85
|
+
// still a context line
|
|
86
|
+
const kindOf = (line: string): Option.Option<LineKind> =>
|
|
87
|
+
line === "" ? Option.some("Same") : Option.fromUndefinedOr(LINE_KIND_BY_PREFIX[line.charAt(0)]);
|
|
88
|
+
|
|
89
|
+
const readHunk = ({ rows, numbers }: PatchState, hunk: StructuredPatchHunk): PatchState => {
|
|
90
|
+
const gap = hunk.oldStart - numbers.old;
|
|
91
|
+
const gapRows = gap > 0 ? [DiffRow.Fold({ count: gap, firstOldNumber: numbers.old })] : [];
|
|
92
|
+
|
|
93
|
+
return Array.reduce(
|
|
94
|
+
hunk.lines,
|
|
95
|
+
{ rows: [...rows, ...gapRows], numbers: { old: hunk.oldStart, new: hunk.newStart } },
|
|
96
|
+
(state: PatchState, line): PatchState =>
|
|
97
|
+
// "" starts with a "\", a prefix that draws no row
|
|
98
|
+
Option.match(kindOf(line), {
|
|
99
|
+
onNone: () => state,
|
|
100
|
+
onSome: (kind) => {
|
|
101
|
+
const [nextNumbers, row] = numberLine(state.numbers, kind, line.slice(1));
|
|
102
|
+
return { rows: [...state.rows, row], numbers: nextNumbers };
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onlyHunksOf = <File extends Readonly<{ hunks: Array<StructuredPatchHunk> }>>(
|
|
109
|
+
files: ReadonlyArray<File>,
|
|
110
|
+
): Option.Option<Array.NonEmptyReadonlyArray<StructuredPatchHunk>> =>
|
|
111
|
+
Array.match(files, {
|
|
112
|
+
onEmpty: () => Option.none(),
|
|
113
|
+
onNonEmpty: ([file, ...others]) =>
|
|
114
|
+
Array.isArrayEmpty(others) && Array.isArrayNonEmpty(file.hunks)
|
|
115
|
+
? Option.some(file.hunks)
|
|
116
|
+
: Option.none(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** The rows of one file's unified diff, numbered from its hunks' headers, with the unchanged
|
|
120
|
+
* lines between hunks folded into a count. `None` unless it is one file with a hunk: text that
|
|
121
|
+
* is not a diff, a binary change and several files have no lines to show. */
|
|
122
|
+
export const diffRowsOfPatch = (patch: string): Option.Option<ReadonlyArray<DiffRow>> =>
|
|
123
|
+
pipe(
|
|
124
|
+
Option.liftThrowable(parsePatch)(patch.replaceAll("\r\n", "\n")),
|
|
125
|
+
Option.flatMap(onlyHunksOf),
|
|
126
|
+
Option.map(
|
|
127
|
+
(hunks) => Array.reduce(hunks, { rows: [], numbers: { old: 1, new: 1 } }, readHunk).rows,
|
|
128
|
+
),
|
|
71
129
|
);
|
|
72
130
|
|
|
73
|
-
|
|
74
|
-
export const hasChanges = (lines: ReadonlyArray<DiffLine>): boolean =>
|
|
75
|
-
Array.some(lines, ({ kind }) => kind !== "Same");
|
|
131
|
+
// FOLD
|
|
76
132
|
|
|
77
133
|
type FoldState = Readonly<{
|
|
78
134
|
rows: ReadonlyArray<DiffRow>;
|
|
79
|
-
folded: ReadonlyArray<
|
|
135
|
+
folded: ReadonlyArray<SameRow>;
|
|
80
136
|
}>;
|
|
81
137
|
|
|
82
138
|
// a run of one stays a line: a row that says "1 unchanged line" takes the same room as the line
|
|
83
|
-
const foldRow = (run: Array.NonEmptyReadonlyArray<
|
|
139
|
+
const foldRow = (run: Array.NonEmptyReadonlyArray<SameRow>): DiffRow =>
|
|
84
140
|
run.length === 1
|
|
85
|
-
?
|
|
86
|
-
: DiffRow.Fold({ count: run.length,
|
|
141
|
+
? Array.headNonEmpty(run)
|
|
142
|
+
: DiffRow.Fold({ count: run.length, firstOldNumber: Array.headNonEmpty(run).oldNumber });
|
|
87
143
|
|
|
88
144
|
const flushFold = ({ rows, folded }: FoldState): ReadonlyArray<DiffRow> =>
|
|
89
145
|
Array.match(folded, {
|
|
@@ -94,17 +150,17 @@ const flushFold = ({ rows, folded }: FoldState): ReadonlyArray<DiffRow> =>
|
|
|
94
150
|
/** The diff's rows: every change with `context` unchanged lines around it, and each longer
|
|
95
151
|
* run of unchanged lines folded into one row that counts them (a run of one stays a line). */
|
|
96
152
|
export const foldUnchanged = (
|
|
97
|
-
|
|
153
|
+
rows: ReadonlyArray<DiffRow>,
|
|
98
154
|
context: number,
|
|
99
155
|
): ReadonlyArray<DiffRow> => {
|
|
100
156
|
const isKept = (index: number): boolean =>
|
|
101
|
-
hasChanges(
|
|
157
|
+
hasChanges(rows.slice(Math.max(0, index - context), index + context + 1));
|
|
102
158
|
|
|
103
159
|
return flushFold(
|
|
104
|
-
Array.reduce(
|
|
105
|
-
isKept(index)
|
|
106
|
-
? { rows:
|
|
107
|
-
: { rows: state
|
|
160
|
+
Array.reduce(rows, { rows: [], folded: [] }, (state: FoldState, row, index): FoldState =>
|
|
161
|
+
row._tag === "Same" && !isKept(index)
|
|
162
|
+
? { rows: state.rows, folded: [...state.folded, row] }
|
|
163
|
+
: { rows: [...flushFold(state), row], folded: [] },
|
|
108
164
|
),
|
|
109
165
|
);
|
|
110
166
|
};
|
package/src/lineDiff/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { view } from "./lineDiff.ts";
|
|
2
|
-
export type { ViewConfig } from "./lineDiff.ts";
|
|
1
|
+
export { patchView, view } from "./lineDiff.ts";
|
|
2
|
+
export type { PatchViewConfig, ViewConfig } from "./lineDiff.ts";
|
package/src/lineDiff/lineDiff.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { Array, Option } from "effect";
|
|
1
|
+
import { Array, Option, String } from "effect";
|
|
2
2
|
import type { Html, HtmlBuilder } from "foldkit/html";
|
|
3
3
|
|
|
4
4
|
import { LOCALE } from "../locale/locale.ts";
|
|
5
5
|
import {
|
|
6
6
|
DiffRow,
|
|
7
|
-
type DiffLine,
|
|
8
7
|
type LineKind,
|
|
9
|
-
|
|
8
|
+
diffRowsOf,
|
|
9
|
+
diffRowsOfPatch,
|
|
10
10
|
foldUnchanged,
|
|
11
11
|
hasChanges,
|
|
12
12
|
} from "./diff.ts";
|
|
@@ -27,58 +27,73 @@ export type ViewConfig = Readonly<{
|
|
|
27
27
|
context?: number;
|
|
28
28
|
}>;
|
|
29
29
|
|
|
30
|
+
/** Configuration for rendering a unified diff with `patchView`. */
|
|
31
|
+
export type PatchViewConfig = Readonly<{
|
|
32
|
+
/** One file's unified diff, as `diff -u` or `git diff -- <path>` writes it. */
|
|
33
|
+
patch: string;
|
|
34
|
+
/** Names the two sides for screen readers: `"Version 3 against version 4"`. */
|
|
35
|
+
label: string;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
30
38
|
const KIND: Record<LineKind, Readonly<{ className: string; sign: string; said: string }>> = {
|
|
31
39
|
Same: { className: "same", sign: "", said: "unchanged" },
|
|
32
40
|
Added: { className: "add", sign: "+", said: "added" },
|
|
33
41
|
Removed: { className: "del", sign: "−", said: "removed" },
|
|
34
42
|
};
|
|
35
43
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const { className, sign, said } = KIND[line.kind];
|
|
44
|
+
const lineView = <Message>(
|
|
45
|
+
kind: LineKind,
|
|
46
|
+
numbers: Readonly<{ old: string; new: string }>,
|
|
47
|
+
text: string,
|
|
48
|
+
h: HtmlBuilder<Message>,
|
|
49
|
+
): Html => {
|
|
50
|
+
const { className, sign, said } = KIND[kind];
|
|
44
51
|
|
|
45
52
|
return h.keyed("tr")(
|
|
46
|
-
|
|
53
|
+
`${numbers.old}:${numbers.new}`,
|
|
47
54
|
[h.Class(className)],
|
|
48
55
|
[
|
|
49
|
-
h.td([h.Class("ln")], [
|
|
50
|
-
h.td([h.Class("ln")], [
|
|
56
|
+
h.td([h.Class("ln")], [numbers.old]),
|
|
57
|
+
h.td([h.Class("ln")], [numbers.new]),
|
|
51
58
|
h.td([h.Class("sign"), h.AriaLabel(said)], [sign]),
|
|
52
|
-
h.td([h.Class("line")], [
|
|
59
|
+
h.td([h.Class("line")], [text === "" ? " " : text]),
|
|
53
60
|
],
|
|
54
61
|
);
|
|
55
62
|
};
|
|
56
63
|
|
|
57
|
-
const foldView = <Message>(count: number,
|
|
64
|
+
const foldView = <Message>(count: number, firstOldNumber: number, h: HtmlBuilder<Message>): Html =>
|
|
58
65
|
h.keyed("tr")(
|
|
59
|
-
`fold-${
|
|
66
|
+
`fold-${firstOldNumber}`,
|
|
60
67
|
[h.Class("fold")],
|
|
61
|
-
[
|
|
68
|
+
[
|
|
69
|
+
h.td(
|
|
70
|
+
[h.Colspan(COLUMN_COUNT)],
|
|
71
|
+
[`${count.toLocaleString(LOCALE)} unchanged ${count === 1 ? "line" : "lines"}`],
|
|
72
|
+
),
|
|
73
|
+
],
|
|
62
74
|
);
|
|
63
75
|
|
|
64
76
|
const rowView = <Message>(row: DiffRow, h: HtmlBuilder<Message>): Html =>
|
|
65
77
|
DiffRow.match(row, {
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
Same: ({ text, oldNumber, newNumber }) =>
|
|
79
|
+
lineView("Same", { old: `${oldNumber}`, new: `${newNumber}` }, text, h),
|
|
80
|
+
Added: ({ text, newNumber }) => lineView("Added", { old: "", new: `${newNumber}` }, text, h),
|
|
81
|
+
Removed: ({ text, oldNumber }) =>
|
|
82
|
+
lineView("Removed", { old: `${oldNumber}`, new: "" }, text, h),
|
|
83
|
+
Fold: ({ count, firstOldNumber }) => foldView(count, firstOldNumber, h),
|
|
68
84
|
});
|
|
69
85
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
* named for screen readers. Two texts that are the same say so in a sentence. */
|
|
73
|
-
export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html => {
|
|
74
|
-
const { before, after, label, context = DEFAULT_CONTEXT } = config;
|
|
75
|
-
const lines = diffLinesOf(before, after);
|
|
86
|
+
const noteView = <Message>(note: string, h: HtmlBuilder<Message>): Html =>
|
|
87
|
+
h.p([h.Class("note-line")], [note]);
|
|
76
88
|
|
|
77
|
-
|
|
78
|
-
return h.p([h.Class("note-line")], ["No difference: both sides hold the same text."]);
|
|
79
|
-
}
|
|
89
|
+
const SAME_NOTE = "No difference: both sides hold the same text.";
|
|
80
90
|
|
|
81
|
-
|
|
91
|
+
const tableView = <Message>(
|
|
92
|
+
rows: ReadonlyArray<DiffRow>,
|
|
93
|
+
label: string,
|
|
94
|
+
h: HtmlBuilder<Message>,
|
|
95
|
+
): Html =>
|
|
96
|
+
h.div(
|
|
82
97
|
[h.Class("diff-wrap")],
|
|
83
98
|
[
|
|
84
99
|
h.table(
|
|
@@ -86,10 +101,40 @@ export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html
|
|
|
86
101
|
[
|
|
87
102
|
h.tbody(
|
|
88
103
|
[],
|
|
89
|
-
Array.map(
|
|
104
|
+
Array.map(rows, (row) => rowView(row, h)),
|
|
90
105
|
),
|
|
91
106
|
],
|
|
92
107
|
),
|
|
93
108
|
],
|
|
94
109
|
);
|
|
110
|
+
|
|
111
|
+
/** A line diff as a ruled table: the old and new numbers, a sign, the line. Added lines sit on
|
|
112
|
+
* the head tint behind an ink +; removed lines go muted behind a red −, and each sign is
|
|
113
|
+
* named for screen readers. Two texts that are the same say so in a sentence. */
|
|
114
|
+
export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html => {
|
|
115
|
+
const { before, after, label, context = DEFAULT_CONTEXT } = config;
|
|
116
|
+
const rows = diffRowsOf(before, after);
|
|
117
|
+
|
|
118
|
+
return hasChanges(rows)
|
|
119
|
+
? tableView(foldUnchanged(rows, context), label, h)
|
|
120
|
+
: noteView(SAME_NOTE, h);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** A unified diff the caller already has (from a server, or `git diff -- <path>`) as `view`'s
|
|
124
|
+
* table: its hunks' lines, numbered from their headers, and the unchanged lines between hunks
|
|
125
|
+
* folded into a counted row. Lines past the last hunk are not in the patch, so nothing counts
|
|
126
|
+
* them. An empty patch says there is no difference, as `view` does; anything but one file
|
|
127
|
+
* with a hunk (text that is not a diff, a binary change, several files) says it cannot be
|
|
128
|
+
* shown as lines. */
|
|
129
|
+
export const patchView = <Message>(config: PatchViewConfig, h: HtmlBuilder<Message>): Html => {
|
|
130
|
+
const { patch, label } = config;
|
|
131
|
+
|
|
132
|
+
if (String.isEmpty(String.trim(patch))) {
|
|
133
|
+
return noteView(SAME_NOTE, h);
|
|
134
|
+
} else {
|
|
135
|
+
return Option.match(diffRowsOfPatch(patch), {
|
|
136
|
+
onNone: () => noteView("This diff cannot be shown as lines.", h),
|
|
137
|
+
onSome: (rows) => tableView(rows, label, h),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
95
140
|
};
|
|
@@ -3,6 +3,7 @@ import { Match, Schema } from "effect";
|
|
|
3
3
|
import type { Html, HtmlBuilder } from "foldkit/html";
|
|
4
4
|
|
|
5
5
|
import { pressedAttribute } from "../button/button.ts";
|
|
6
|
+
import { describedByError, errorView } from "../field/field.ts";
|
|
6
7
|
import * as IconButton from "../iconButton/iconButton.ts";
|
|
7
8
|
import * as Meter from "../meter/meter.ts";
|
|
8
9
|
|
|
@@ -41,8 +42,10 @@ export type ViewConfig<Message> = Readonly<{
|
|
|
41
42
|
onToggleShown: Message;
|
|
42
43
|
/** A sans sentence under the box. */
|
|
43
44
|
help?: string;
|
|
44
|
-
/**
|
|
45
|
-
|
|
45
|
+
/** Why the value was refused: a red sentence under the control, which the control is
|
|
46
|
+
* described by, with red on its box and its label (`aria-invalid`). Say it again in an
|
|
47
|
+
* ErrorSummary above the form. */
|
|
48
|
+
error?: string | undefined;
|
|
46
49
|
/** The form is not sent without it: screen readers hear "required" (`aria-required`). */
|
|
47
50
|
isRequired?: boolean;
|
|
48
51
|
/** For a new password: four short cells and a word under the box (an empty box is "too
|
|
@@ -50,13 +53,16 @@ export type ViewConfig<Message> = Readonly<{
|
|
|
50
53
|
strength?: Strength;
|
|
51
54
|
/** `current-password` to sign in, `new-password` to set one. */
|
|
52
55
|
autocomplete?: "current-password" | "new-password";
|
|
56
|
+
/** The show toggle's name, "Show password" when not given: give each of two fields in a
|
|
57
|
+
* form its own, such as "Show password again". */
|
|
58
|
+
toggleLabel?: string;
|
|
53
59
|
}>;
|
|
54
60
|
|
|
55
61
|
/** A boxed password field with a show toggle inside its box, built on `@foldkit/ui`'s Input:
|
|
56
|
-
* named by its label, described by its help line, `aria-invalid`
|
|
57
|
-
* `aria-required` when the form is not sent without it. The
|
|
58
|
-
* password", `aria-pressed` while the
|
|
59
|
-
* meter. */
|
|
62
|
+
* named by its label, described by its help line, `aria-invalid` and described by its
|
|
63
|
+
* `error` when it failed, and `aria-required` when the form is not sent without it. The
|
|
64
|
+
* toggle is a button named `toggleLabel` ("Show password"), `aria-pressed` while the
|
|
65
|
+
* password shows. For a new password, its strength is a meter. */
|
|
60
66
|
export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Message>): Html => {
|
|
61
67
|
const {
|
|
62
68
|
id,
|
|
@@ -66,10 +72,11 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
66
72
|
isShown,
|
|
67
73
|
onToggleShown,
|
|
68
74
|
help,
|
|
69
|
-
|
|
75
|
+
error,
|
|
70
76
|
isRequired = false,
|
|
71
77
|
strength,
|
|
72
78
|
autocomplete = "current-password",
|
|
79
|
+
toggleLabel = "Show password",
|
|
73
80
|
} = config;
|
|
74
81
|
|
|
75
82
|
return UiInput.view(
|
|
@@ -77,7 +84,7 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
77
84
|
id,
|
|
78
85
|
value,
|
|
79
86
|
onInput,
|
|
80
|
-
isInvalid,
|
|
87
|
+
isInvalid: error !== undefined,
|
|
81
88
|
type: isShown ? "text" : "password",
|
|
82
89
|
hasDescription: help !== undefined,
|
|
83
90
|
toView: (groups) =>
|
|
@@ -92,12 +99,13 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
92
99
|
h.input([
|
|
93
100
|
...groups.input,
|
|
94
101
|
h.Autocomplete(autocomplete),
|
|
102
|
+
...describedByError(id, error, help !== undefined, h),
|
|
95
103
|
...(isRequired ? [h.AriaRequired(true)] : []),
|
|
96
104
|
]),
|
|
97
105
|
IconButton.view(
|
|
98
106
|
{
|
|
99
107
|
glyph: isShown ? "eye-off" : "eye",
|
|
100
|
-
label:
|
|
108
|
+
label: toggleLabel,
|
|
101
109
|
isQuiet: true,
|
|
102
110
|
onClick: onToggleShown,
|
|
103
111
|
attributes: [pressedAttribute(isShown, h)],
|
|
@@ -106,6 +114,7 @@ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Messag
|
|
|
106
114
|
),
|
|
107
115
|
],
|
|
108
116
|
),
|
|
117
|
+
...errorView(id, error, h),
|
|
109
118
|
...(strength === undefined
|
|
110
119
|
? []
|
|
111
120
|
: [
|
package/src/pixel-paper.css
CHANGED
|
@@ -924,7 +924,9 @@ a.textbtn {
|
|
|
924
924
|
min-width: 0;
|
|
925
925
|
}
|
|
926
926
|
|
|
927
|
-
|
|
927
|
+
/* a sheet takes a panel's place on the page, so it prints as one */
|
|
928
|
+
.panel,
|
|
929
|
+
.sheet {
|
|
928
930
|
background: var(--paper);
|
|
929
931
|
border: var(--line) solid var(--ink);
|
|
930
932
|
box-shadow: var(--shadow-panel);
|
|
@@ -1354,7 +1356,8 @@ button.glyph {
|
|
|
1354
1356
|
margin: -4px;
|
|
1355
1357
|
}
|
|
1356
1358
|
|
|
1357
|
-
/* an inline edit in a row shows its focus as its caret and its rule, not a
|
|
1359
|
+
/* an inline edit in a row shows its focus as its caret and its rule, thickened below, not a
|
|
1360
|
+
ring */
|
|
1358
1361
|
.row .grow input.inline-input:focus-visible {
|
|
1359
1362
|
outline: none;
|
|
1360
1363
|
}
|
|
@@ -1611,6 +1614,17 @@ input.inline-input[aria-invalid="true"] {
|
|
|
1611
1614
|
border-bottom-color: var(--red);
|
|
1612
1615
|
}
|
|
1613
1616
|
|
|
1617
|
+
/* in a row, which takes its ring, focus doubles the rule to 2px, as a field's baseline does;
|
|
1618
|
+
drawn inside the box, so the row keeps its height, and in the rule's own colour, so a
|
|
1619
|
+
refused edit stays red */
|
|
1620
|
+
.row .grow input.inline-input:focus-visible {
|
|
1621
|
+
box-shadow: inset 0 -1px 0 var(--ink);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
.row .grow input.inline-input[aria-invalid="true"]:focus-visible {
|
|
1625
|
+
box-shadow: inset 0 -1px 0 var(--red);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1614
1628
|
/* the flash word: a transient confirmation printed beside the action, then gone */
|
|
1615
1629
|
.flash-word {
|
|
1616
1630
|
font-family: var(--font-px);
|
|
@@ -1853,12 +1867,18 @@ textarea::-webkit-resizer {
|
|
|
1853
1867
|
color: var(--red);
|
|
1854
1868
|
}
|
|
1855
1869
|
|
|
1856
|
-
.field .field-help
|
|
1870
|
+
.field .field-help,
|
|
1871
|
+
.field .field-error {
|
|
1857
1872
|
margin-top: 4px;
|
|
1858
1873
|
font: var(--type-control)/1.45 var(--font-body);
|
|
1859
1874
|
color: var(--muted);
|
|
1860
1875
|
}
|
|
1861
1876
|
|
|
1877
|
+
/* why the field failed, said in red ink under the control and before its help */
|
|
1878
|
+
.field .field-error {
|
|
1879
|
+
color: var(--red);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1862
1882
|
.field-grid {
|
|
1863
1883
|
display: grid;
|
|
1864
1884
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
@@ -2961,7 +2981,8 @@ dialog.veil:focus {
|
|
|
2961
2981
|
justify-items: center;
|
|
2962
2982
|
}
|
|
2963
2983
|
|
|
2964
|
-
.empty
|
|
2984
|
+
.empty-title {
|
|
2985
|
+
margin: 0;
|
|
2965
2986
|
font-family: var(--font-px);
|
|
2966
2987
|
font-size: var(--type-control);
|
|
2967
2988
|
text-transform: uppercase;
|
|
@@ -3971,7 +3992,7 @@ dialog.veil:focus {
|
|
|
3971
3992
|
outline-offset: 2px;
|
|
3972
3993
|
}
|
|
3973
3994
|
|
|
3974
|
-
/* ---------- the reading sheet: an authored page
|
|
3995
|
+
/* ---------- the reading sheet: an authored page in a panel's place ---------- */
|
|
3975
3996
|
|
|
3976
3997
|
.sheet {
|
|
3977
3998
|
padding: 28px 32px 36px;
|