@charcuterie/ui 3.0.0 → 3.1.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/README.md +10 -0
- package/dist/Badge/Badge.d.ts +33 -3
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +20 -2
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Board/Board.d.ts +119 -0
- package/dist/Board/Board.d.ts.map +1 -0
- package/dist/Board/Board.js +165 -0
- package/dist/Board/Board.js.map +1 -0
- package/dist/Board/BoardCard.d.ts +120 -0
- package/dist/Board/BoardCard.d.ts.map +1 -0
- package/dist/Board/BoardCard.js +77 -0
- package/dist/Board/BoardCard.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +103 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -0
- package/dist/Board/BoardLaneList.js +69 -0
- package/dist/Board/BoardLaneList.js.map +1 -0
- package/dist/Board/boardMove.d.ts +116 -0
- package/dist/Board/boardMove.d.ts.map +1 -0
- package/dist/Board/boardMove.js +116 -0
- package/dist/Board/boardMove.js.map +1 -0
- package/dist/Board/useBoardDrag.d.ts +44 -0
- package/dist/Board/useBoardDrag.d.ts.map +1 -0
- package/dist/Board/useBoardDrag.js +237 -0
- package/dist/Board/useBoardDrag.js.map +1 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +15 -4
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/DataTable/DataTable.d.ts +159 -0
- package/dist/DataTable/DataTable.d.ts.map +1 -0
- package/dist/DataTable/DataTable.js +197 -0
- package/dist/DataTable/DataTable.js.map +1 -0
- package/dist/DatePicker/DateGrid.d.ts +79 -0
- package/dist/DatePicker/DateGrid.d.ts.map +1 -0
- package/dist/DatePicker/DateGrid.js +220 -0
- package/dist/DatePicker/DateGrid.js.map +1 -0
- package/dist/DatePicker/DatePicker.d.ts +138 -0
- package/dist/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/DatePicker/DatePicker.js +384 -0
- package/dist/DatePicker/DatePicker.js.map +1 -0
- package/dist/DatePicker/datePresets.d.ts +29 -0
- package/dist/DatePicker/datePresets.d.ts.map +1 -0
- package/dist/DatePicker/datePresets.js +28 -0
- package/dist/DatePicker/datePresets.js.map +1 -0
- package/dist/DatePicker/parseDateInput.d.ts +106 -0
- package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
- package/dist/DatePicker/parseDateInput.js +419 -0
- package/dist/DatePicker/parseDateInput.js.map +1 -0
- package/dist/DatePicker/plainDate.d.ts +167 -0
- package/dist/DatePicker/plainDate.d.ts.map +1 -0
- package/dist/DatePicker/plainDate.js +272 -0
- package/dist/DatePicker/plainDate.js.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.js +379 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.js +303 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
- package/dist/categoricalStyles.d.ts +53 -0
- package/dist/categoricalStyles.d.ts.map +1 -0
- package/dist/categoricalStyles.js +195 -0
- package/dist/categoricalStyles.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Badge/Badge.mdx +46 -0
- package/src/Badge/Badge.stories.tsx +98 -4
- package/src/Badge/Badge.test.tsx +157 -2
- package/src/Badge/Badge.tsx +49 -3
- package/src/Board/Board.mdx +199 -0
- package/src/Board/Board.stories.tsx +707 -0
- package/src/Board/Board.test.tsx +310 -0
- package/src/Board/Board.tsx +333 -0
- package/src/Board/BoardCard.tsx +290 -0
- package/src/Board/BoardLaneList.tsx +275 -0
- package/src/Board/boardMove.test.ts +213 -0
- package/src/Board/boardMove.ts +225 -0
- package/src/Board/useBoardDrag.ts +413 -0
- package/src/Checkbox/Checkbox.tsx +34 -3
- package/src/DataTable/DataTable.mdx +123 -0
- package/src/DataTable/DataTable.stories.tsx +411 -0
- package/src/DataTable/DataTable.test.tsx +386 -0
- package/src/DataTable/DataTable.tsx +497 -0
- package/src/DatePicker/DateGrid.tsx +467 -0
- package/src/DatePicker/DatePicker.mdx +170 -0
- package/src/DatePicker/DatePicker.stories.tsx +268 -0
- package/src/DatePicker/DatePicker.test.tsx +455 -0
- package/src/DatePicker/DatePicker.tsx +852 -0
- package/src/DatePicker/datePresets.ts +34 -0
- package/src/DatePicker/parseDateInput.test.ts +187 -0
- package/src/DatePicker/parseDateInput.ts +629 -0
- package/src/DatePicker/plainDate.test.ts +375 -0
- package/src/DatePicker/plainDate.ts +465 -0
- package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
- package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
- package/src/MarkdownEditor/markdownCommands.ts +566 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
- package/src/MarkdownEditor/markdownSpans.ts +458 -0
- package/src/argTypes.storyHelpers.ts +39 -0
- package/src/categoricalStyles.ts +244 -0
- package/src/index.ts +89 -0
- package/src/sourceRules.test.ts +52 -1
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A calendar date, and deliberately not an instant.
|
|
3
|
+
*
|
|
4
|
+
* ### The bug this file exists to make impossible
|
|
5
|
+
*
|
|
6
|
+
* A due date is not a moment. "This is due on the 19th" is true in
|
|
7
|
+
* Denver and true in Tokyo, and it does not become the 18th because
|
|
8
|
+
* a laptop crossed a meridian. The classic date-picker defect is
|
|
9
|
+
* that the value is a `Date` — which is an instant, a count of
|
|
10
|
+
* milliseconds — and every read of it goes through the *browser's*
|
|
11
|
+
* timezone. Pick the 19th at 23:30 in Denver, store
|
|
12
|
+
* `new Date("2026-08-19").toISOString()`, and the server, the next
|
|
13
|
+
* device, and the same device after a flight all disagree about
|
|
14
|
+
* which day you meant. It is a one-day error, it only appears near
|
|
15
|
+
* midnight or after travel, and it is invisible in every test
|
|
16
|
+
* written at noon.
|
|
17
|
+
*
|
|
18
|
+
* So the value here is a **plain date**: a year, a month, and a day,
|
|
19
|
+
* with no time, no offset, and no zone. It is stored as an ISO
|
|
20
|
+
* `YYYY-MM-DD` string — which is exactly `<input type="date">`'s own
|
|
21
|
+
* `value` format, so a consumer can move between this component and
|
|
22
|
+
* the native control without a migration.
|
|
23
|
+
*
|
|
24
|
+
* ### There is no `Date` arithmetic in here at all
|
|
25
|
+
*
|
|
26
|
+
* Not "carefully-written `Date` arithmetic" — none. Every operation
|
|
27
|
+
* below is integer maths on a **day number**: the count of days
|
|
28
|
+
* since 1970-01-01, computed with Howard Hinnant's `days_from_civil`
|
|
29
|
+
* / `civil_from_days` (public domain, the algorithm C++20's
|
|
30
|
+
* `<chrono>` and Rust's `chrono` both use). Adding a day is `+ 1`.
|
|
31
|
+
* That is a proleptic Gregorian calendar with no DST, no leap
|
|
32
|
+
* seconds, and no zone, which is what a calendar date actually is.
|
|
33
|
+
*
|
|
34
|
+
* A `Date` appears in exactly two places, both of them named and
|
|
35
|
+
* both of them fenced:
|
|
36
|
+
*
|
|
37
|
+
* - `getLocalPlainDate`, which is the **one** conversion from an
|
|
38
|
+
* instant to a calendar date, and which requires you to say
|
|
39
|
+
* which zone's calendar you mean.
|
|
40
|
+
* - `formatPlainDate` and its callers, which hand `Intl` a date
|
|
41
|
+
* built at UTC midnight and format it with `timeZone: "UTC"`, so
|
|
42
|
+
* the formatter cannot shift the day either.
|
|
43
|
+
*
|
|
44
|
+
* `plainDate.test.ts` re-runs the whole suite under four `TZ`
|
|
45
|
+
* values, including UTC+14 and UTC-11, and asserts every answer is
|
|
46
|
+
* byte-identical.
|
|
47
|
+
*
|
|
48
|
+
* ### Why not `Temporal`
|
|
49
|
+
*
|
|
50
|
+
* `Temporal.PlainDate` is this type, done properly, and it is
|
|
51
|
+
* available in the Node this repo builds on. It is not available in
|
|
52
|
+
* every browser a published package has to run in, and the polyfill
|
|
53
|
+
* is ~50 KB gz — an order of magnitude more than this entire
|
|
54
|
+
* component. The functions below are the ~80 lines of it a date
|
|
55
|
+
* picker needs. When `Temporal` is baseline, this file becomes a
|
|
56
|
+
* thin adapter and the public API — ISO strings — does not change,
|
|
57
|
+
* which is the reason the API is ISO strings.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** Year, month (1-12), day (1-31). No time, no zone. */
|
|
61
|
+
export type PlainDate = {
|
|
62
|
+
day: number
|
|
63
|
+
month: number
|
|
64
|
+
year: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A start and an end, either of which may be absent — because a
|
|
69
|
+
* half-picked range is a real state a user spends time in, not an
|
|
70
|
+
* error. `start` with a null `end` is "I have clicked once".
|
|
71
|
+
*/
|
|
72
|
+
export type DateRange = {
|
|
73
|
+
end: null | string
|
|
74
|
+
start: null | string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MONTH_LENGTHS = [
|
|
78
|
+
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
82
|
+
|
|
83
|
+
export const getIsLeapYear = (year: number): boolean =>
|
|
84
|
+
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
|
|
85
|
+
|
|
86
|
+
export const getDaysInMonth = (
|
|
87
|
+
year: number,
|
|
88
|
+
month: number,
|
|
89
|
+
): number =>
|
|
90
|
+
month === 2 && getIsLeapYear(year)
|
|
91
|
+
? 29
|
|
92
|
+
: (MONTH_LENGTHS[month - 1] ?? 30)
|
|
93
|
+
|
|
94
|
+
export const getIsValidPlainDate = ({
|
|
95
|
+
day,
|
|
96
|
+
month,
|
|
97
|
+
year,
|
|
98
|
+
}: PlainDate): boolean =>
|
|
99
|
+
Number.isInteger(year) &&
|
|
100
|
+
Number.isInteger(month) &&
|
|
101
|
+
Number.isInteger(day) &&
|
|
102
|
+
year >= 1 &&
|
|
103
|
+
year <= 9999 &&
|
|
104
|
+
month >= 1 &&
|
|
105
|
+
month <= 12 &&
|
|
106
|
+
day >= 1 &&
|
|
107
|
+
day <= getDaysInMonth(year, month)
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Days since 1970-01-01, by Hinnant's `days_from_civil`.
|
|
111
|
+
*
|
|
112
|
+
* Every other operation in this file is expressed through this and
|
|
113
|
+
* its inverse, which is why "add a month, then add 40 days, then
|
|
114
|
+
* ask what weekday it is" cannot drift: there is one representation
|
|
115
|
+
* and it is an integer.
|
|
116
|
+
*/
|
|
117
|
+
export const getDayNumber = ({
|
|
118
|
+
day,
|
|
119
|
+
month,
|
|
120
|
+
year,
|
|
121
|
+
}: PlainDate): number => {
|
|
122
|
+
const shiftedYear = year - (month <= 2 ? 1 : 0)
|
|
123
|
+
|
|
124
|
+
const era = Math.floor(shiftedYear / 400)
|
|
125
|
+
|
|
126
|
+
const yearOfEra = shiftedYear - era * 400
|
|
127
|
+
|
|
128
|
+
const dayOfYear =
|
|
129
|
+
Math.floor(
|
|
130
|
+
(153 * (month + (month > 2 ? -3 : 9)) + 2) / 5,
|
|
131
|
+
) +
|
|
132
|
+
day -
|
|
133
|
+
1
|
|
134
|
+
|
|
135
|
+
const dayOfEra =
|
|
136
|
+
yearOfEra * 365 +
|
|
137
|
+
Math.floor(yearOfEra / 4) -
|
|
138
|
+
Math.floor(yearOfEra / 100) +
|
|
139
|
+
dayOfYear
|
|
140
|
+
|
|
141
|
+
return era * 146097 + dayOfEra - 719468
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The inverse — Hinnant's `civil_from_days`. */
|
|
145
|
+
export const getPlainDateFromDayNumber = (
|
|
146
|
+
dayNumber: number,
|
|
147
|
+
): PlainDate => {
|
|
148
|
+
const shifted = dayNumber + 719468
|
|
149
|
+
|
|
150
|
+
const era = Math.floor(shifted / 146097)
|
|
151
|
+
|
|
152
|
+
const dayOfEra = shifted - era * 146097
|
|
153
|
+
|
|
154
|
+
const yearOfEra = Math.floor(
|
|
155
|
+
(dayOfEra -
|
|
156
|
+
Math.floor(dayOfEra / 1460) +
|
|
157
|
+
Math.floor(dayOfEra / 36524) -
|
|
158
|
+
Math.floor(dayOfEra / 146096)) /
|
|
159
|
+
365,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const dayOfYear =
|
|
163
|
+
dayOfEra -
|
|
164
|
+
(365 * yearOfEra +
|
|
165
|
+
Math.floor(yearOfEra / 4) -
|
|
166
|
+
Math.floor(yearOfEra / 100))
|
|
167
|
+
|
|
168
|
+
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153)
|
|
169
|
+
|
|
170
|
+
const day =
|
|
171
|
+
dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
|
|
172
|
+
|
|
173
|
+
const month = monthPrime + (monthPrime < 10 ? 3 : -9)
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
day,
|
|
177
|
+
month,
|
|
178
|
+
year: yearOfEra + era * 400 + (month <= 2 ? 1 : 0),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const addDays = (
|
|
183
|
+
date: PlainDate,
|
|
184
|
+
days: number,
|
|
185
|
+
): PlainDate =>
|
|
186
|
+
getPlainDateFromDayNumber(getDayNumber(date) + days)
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Month arithmetic clamps the day rather than overflowing, which is
|
|
190
|
+
* the answer every calendar app gives and the one `Date` does not:
|
|
191
|
+
* `new Date(2026, 0, 31)` plus a month is **3 March**, because 31
|
|
192
|
+
* February rolls forward. One month after 31 January is 28
|
|
193
|
+
* February.
|
|
194
|
+
*/
|
|
195
|
+
export const addMonths = (
|
|
196
|
+
date: PlainDate,
|
|
197
|
+
months: number,
|
|
198
|
+
): PlainDate => {
|
|
199
|
+
const monthIndex =
|
|
200
|
+
date.year * 12 + (date.month - 1) + months
|
|
201
|
+
|
|
202
|
+
const year = Math.floor(monthIndex / 12)
|
|
203
|
+
|
|
204
|
+
const month = monthIndex - year * 12 + 1
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
day: Math.min(date.day, getDaysInMonth(year, month)),
|
|
208
|
+
month,
|
|
209
|
+
year,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const comparePlainDates = (
|
|
214
|
+
first: PlainDate,
|
|
215
|
+
second: PlainDate,
|
|
216
|
+
): number => getDayNumber(first) - getDayNumber(second)
|
|
217
|
+
|
|
218
|
+
export const getIsSameDay = (
|
|
219
|
+
first: PlainDate,
|
|
220
|
+
second: PlainDate,
|
|
221
|
+
): boolean => comparePlainDates(first, second) === 0
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whole days from `from` to `to`. Docket's staleness threshold is
|
|
225
|
+
* counted in days, and this is the subtraction that counts it —
|
|
226
|
+
* with no hour-of-day and no DST to make "14 days" occasionally mean
|
|
227
|
+
* 13.958.
|
|
228
|
+
*/
|
|
229
|
+
export const getDaysBetween = (
|
|
230
|
+
from: PlainDate,
|
|
231
|
+
to: PlainDate,
|
|
232
|
+
): number => getDayNumber(to) - getDayNumber(from)
|
|
233
|
+
|
|
234
|
+
export const clampPlainDate = (
|
|
235
|
+
date: PlainDate,
|
|
236
|
+
minimum?: PlainDate,
|
|
237
|
+
maximum?: PlainDate,
|
|
238
|
+
): PlainDate => {
|
|
239
|
+
if (minimum && comparePlainDates(date, minimum) < 0) {
|
|
240
|
+
return minimum
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (maximum && comparePlainDates(date, maximum) > 0) {
|
|
244
|
+
return maximum
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return date
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const getIsWithinRange = (
|
|
251
|
+
date: PlainDate,
|
|
252
|
+
minimum?: PlainDate,
|
|
253
|
+
maximum?: PlainDate,
|
|
254
|
+
): boolean =>
|
|
255
|
+
(!minimum || comparePlainDates(date, minimum) >= 0) &&
|
|
256
|
+
(!maximum || comparePlainDates(date, maximum) <= 0)
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 0 is Sunday, 6 is Saturday — the numbering `Intl`'s `weekday`
|
|
260
|
+
* option and `Date.prototype.getDay` both use, so the two never
|
|
261
|
+
* need reconciling at a call site.
|
|
262
|
+
*
|
|
263
|
+
* Day number 0 (1970-01-01) was a Thursday, which is the `+ 4`.
|
|
264
|
+
*/
|
|
265
|
+
export const getWeekday = (date: PlainDate): number => {
|
|
266
|
+
const dayNumber = getDayNumber(date)
|
|
267
|
+
|
|
268
|
+
return ((((dayNumber % 7) + 7) % 7) + 4) % 7
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const pad = (value: number, length: number) =>
|
|
272
|
+
String(value).padStart(length, "0")
|
|
273
|
+
|
|
274
|
+
export const toIsoDate = (date: PlainDate): string =>
|
|
275
|
+
`${pad(date.year, 4)}-${pad(date.month, 2)}-${pad(date.day, 2)}`
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* `null` rather than a throw, and `null` rather than a "best
|
|
279
|
+
* effort" date. A string that is not a calendar date has no nearest
|
|
280
|
+
* calendar date, and inventing one is the silent-guess failure this
|
|
281
|
+
* component is built to avoid.
|
|
282
|
+
*/
|
|
283
|
+
export const parseIsoDate = (
|
|
284
|
+
isoDate: null | string | undefined,
|
|
285
|
+
): null | PlainDate => {
|
|
286
|
+
if (!isoDate) {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const match = ISO_DATE_PATTERN.exec(isoDate)
|
|
291
|
+
|
|
292
|
+
if (!match) {
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const date = {
|
|
297
|
+
day: Number(match[3]),
|
|
298
|
+
month: Number(match[2]),
|
|
299
|
+
year: Number(match[1]),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return getIsValidPlainDate(date) ? date : null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A `Date` pinned at UTC midnight, for `Intl` and for nothing else.
|
|
307
|
+
*
|
|
308
|
+
* `Date.UTC` is not used, because it maps years 0-99 onto 1900-1999
|
|
309
|
+
* — a two-digit year silently becomes the twentieth century inside
|
|
310
|
+
* the formatter, which is a bug the *parser* takes an explicit
|
|
311
|
+
* position on and the formatter has no business re-deciding.
|
|
312
|
+
*/
|
|
313
|
+
const toUtcInstant = (date: PlainDate) => {
|
|
314
|
+
const instant = new Date(0)
|
|
315
|
+
|
|
316
|
+
instant.setUTCFullYear(
|
|
317
|
+
date.year,
|
|
318
|
+
date.month - 1,
|
|
319
|
+
date.day,
|
|
320
|
+
)
|
|
321
|
+
instant.setUTCHours(0, 0, 0, 0)
|
|
322
|
+
|
|
323
|
+
return instant
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export type PlainDateFormatOptions =
|
|
327
|
+
Intl.DateTimeFormatOptions & {
|
|
328
|
+
locale?: string
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `timeZone: "UTC"` is forced, and it is not configurable.
|
|
333
|
+
*
|
|
334
|
+
* The instant above is UTC midnight, so formatting it in any other
|
|
335
|
+
* zone renders the previous or the next day — which is the same
|
|
336
|
+
* one-day error as storing an instant, arriving through the display
|
|
337
|
+
* layer instead. There is no legitimate reason to format a calendar
|
|
338
|
+
* date in a zone: the date *is* the answer.
|
|
339
|
+
*/
|
|
340
|
+
export const formatPlainDate = (
|
|
341
|
+
date: PlainDate,
|
|
342
|
+
{ locale, ...options }: PlainDateFormatOptions = {},
|
|
343
|
+
): string =>
|
|
344
|
+
new Intl.DateTimeFormat(locale, {
|
|
345
|
+
...options,
|
|
346
|
+
timeZone: "UTC",
|
|
347
|
+
}).format(toUtcInstant(date))
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The one instant-to-calendar-date conversion in the library, and it
|
|
351
|
+
* makes you name the zone.
|
|
352
|
+
*
|
|
353
|
+
* Defaulting `timeZone` to the device's own resolved zone is right
|
|
354
|
+
* for "what is today on this screen"; passing one explicitly is
|
|
355
|
+
* right for "what is today for this household". Either way the
|
|
356
|
+
* question is asked out loud, which is the difference between this
|
|
357
|
+
* and `new Date().toISOString().slice(0, 10)` — a line that appears
|
|
358
|
+
* in three of the fleet's repos and is wrong for everybody west of
|
|
359
|
+
* Greenwich after 17:00.
|
|
360
|
+
*/
|
|
361
|
+
export const getLocalPlainDate = (
|
|
362
|
+
now: Date,
|
|
363
|
+
timeZone?: string,
|
|
364
|
+
): PlainDate => {
|
|
365
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
366
|
+
day: "2-digit",
|
|
367
|
+
month: "2-digit",
|
|
368
|
+
timeZone,
|
|
369
|
+
year: "numeric",
|
|
370
|
+
}).formatToParts(now)
|
|
371
|
+
|
|
372
|
+
const read = (type: string) =>
|
|
373
|
+
Number(
|
|
374
|
+
parts.find((part) => part.type === type)?.value ??
|
|
375
|
+
"0",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
day: read("day"),
|
|
380
|
+
month: read("month"),
|
|
381
|
+
year: read("year"),
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Which weekday a calendar starts on, from the locale rather than
|
|
387
|
+
* from a guess.
|
|
388
|
+
*
|
|
389
|
+
* `Intl.Locale.prototype.getWeekInfo` is the platform's own answer
|
|
390
|
+
* (Monday in most of the world, Sunday in the US and Japan, Saturday
|
|
391
|
+
* in much of the Middle East) and it is the whole reason this
|
|
392
|
+
* component does not ship a `isMondayFirst` boolean. It is not in
|
|
393
|
+
* TypeScript's `lib.es*` yet, so the shape is stated here and the
|
|
394
|
+
* absence is handled — a runtime without it falls back to Sunday,
|
|
395
|
+
* which is `Date.prototype.getDay`'s own origin and therefore the
|
|
396
|
+
* least surprising default.
|
|
397
|
+
*/
|
|
398
|
+
type LocaleWithWeekInfo = {
|
|
399
|
+
getWeekInfo?: () => { firstDay?: number }
|
|
400
|
+
weekInfo?: { firstDay?: number }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export const getFirstDayOfWeek = (
|
|
404
|
+
locale?: string,
|
|
405
|
+
): number => {
|
|
406
|
+
const resolved = new Intl.Locale(
|
|
407
|
+
locale ??
|
|
408
|
+
new Intl.DateTimeFormat().resolvedOptions().locale,
|
|
409
|
+
) as Intl.Locale & LocaleWithWeekInfo
|
|
410
|
+
|
|
411
|
+
const firstDay =
|
|
412
|
+
resolved.getWeekInfo?.().firstDay ??
|
|
413
|
+
resolved.weekInfo?.firstDay
|
|
414
|
+
|
|
415
|
+
// `getWeekInfo` numbers Monday 1 through Sunday 7; this file
|
|
416
|
+
// numbers Sunday 0 through Saturday 6.
|
|
417
|
+
return firstDay === undefined ? 0 : firstDay % 7
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The seven weekday names, rotated so index 0 is the locale's own
|
|
422
|
+
* first day. A fixed sample week is formatted rather than a
|
|
423
|
+
* hand-written table, so every locale the browser knows is covered
|
|
424
|
+
* and nothing here needs translating.
|
|
425
|
+
*/
|
|
426
|
+
export const getWeekdayNames = ({
|
|
427
|
+
firstDayOfWeek,
|
|
428
|
+
locale,
|
|
429
|
+
weekday,
|
|
430
|
+
}: {
|
|
431
|
+
firstDayOfWeek: number
|
|
432
|
+
locale?: string
|
|
433
|
+
weekday: "long" | "narrow" | "short"
|
|
434
|
+
}): string[] => {
|
|
435
|
+
// 2026-08-16 is a Sunday, so `+ index` walks Sunday to Saturday.
|
|
436
|
+
const sundayDayNumber = getDayNumber({
|
|
437
|
+
day: 16,
|
|
438
|
+
month: 8,
|
|
439
|
+
year: 2026,
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
return Array.from({ length: 7 }, (_unused, index) =>
|
|
443
|
+
formatPlainDate(
|
|
444
|
+
getPlainDateFromDayNumber(
|
|
445
|
+
sundayDayNumber + ((firstDayOfWeek + index) % 7),
|
|
446
|
+
),
|
|
447
|
+
{ locale, weekday },
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** January-first month names, for the parser and the header. */
|
|
453
|
+
export const getMonthNames = ({
|
|
454
|
+
locale,
|
|
455
|
+
month,
|
|
456
|
+
}: {
|
|
457
|
+
locale?: string
|
|
458
|
+
month: "long" | "short"
|
|
459
|
+
}): string[] =>
|
|
460
|
+
Array.from({ length: 12 }, (_unused, index) =>
|
|
461
|
+
formatPlainDate(
|
|
462
|
+
{ day: 1, month: index + 1, year: 2026 },
|
|
463
|
+
{ locale, month },
|
|
464
|
+
),
|
|
465
|
+
)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as MarkdownEditorStories from "./MarkdownEditor.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={MarkdownEditorStories} />
|
|
6
|
+
|
|
7
|
+
# MarkdownEditor
|
|
8
|
+
|
|
9
|
+
A live hybrid markdown editor whose stored value is markdown and **only** markdown.
|
|
10
|
+
|
|
11
|
+
Docket asked for this one and its requirement is unusually sharp, because the tracker it
|
|
12
|
+
replaces destroyed data by storing HTML in a field it later read back as text. So the
|
|
13
|
+
constraint is not "prefer markdown" — it is that an HTML tag must not be *able* to reach the
|
|
14
|
+
stored string.
|
|
15
|
+
|
|
16
|
+
<Canvas of={MarkdownEditorStories.Default} />
|
|
17
|
+
|
|
18
|
+
## Props
|
|
19
|
+
|
|
20
|
+
<Controls />
|
|
21
|
+
|
|
22
|
+
## No HTML can reach the value, by construction
|
|
23
|
+
|
|
24
|
+
Four properties, and none of them is a rule somebody has to remember:
|
|
25
|
+
|
|
26
|
+
- the editing surface is a `<textarea>`, which takes **`text/plain`** off the clipboard and
|
|
27
|
+
nothing else. Copy a styled paragraph out of a browser and the `text/html` flavour is
|
|
28
|
+
simply never read;
|
|
29
|
+
- every command in `markdownCommands.ts` returns markdown. There is no serialiser and no
|
|
30
|
+
intermediate document model anywhere in the component;
|
|
31
|
+
- the painted layer emits `{ kind, text }` spans that React renders as **text nodes**. No
|
|
32
|
+
`innerHTML`, no sanitiser, and nothing to get wrong — a `<script>` in the source is a
|
|
33
|
+
handful of coloured characters on screen;
|
|
34
|
+
- image insertion escapes its alt text and brackets a dirty URL, so a filename with a `]` in
|
|
35
|
+
it cannot end the construct early.
|
|
36
|
+
|
|
37
|
+
`markdownCommands.test.ts` runs every command over a value full of tags and entities and
|
|
38
|
+
asserts each fragment survives **verbatim** — not "contains no `<`", because the fixture
|
|
39
|
+
deliberately contains `<` as literal text and a naive absence check would pass on an
|
|
40
|
+
escaped value and fail on a correct one.
|
|
41
|
+
|
|
42
|
+
<Canvas of={MarkdownEditorStories.AllVariants} />
|
|
43
|
+
|
|
44
|
+
## Why a `<textarea>` and not an editor core
|
|
45
|
+
|
|
46
|
+
Charcuterie is published, so a dependency here is a permanent cost to every consumer.
|
|
47
|
+
Measured on this machine — `esbuild --bundle --minify`, React external, gzip -9:
|
|
48
|
+
|
|
49
|
+
| Candidate | minified | gzipped |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| TipTap 3.30 + starter kit + `tiptap-markdown` | 720 KB | **238 KB** |
|
|
52
|
+
| CodeMirror 6 (`basicSetup` + `lang-markdown`) | 609 KB | **206 KB** |
|
|
53
|
+
| CodeMirror 6, hand-assembled minimum | 512 KB | **176 KB** |
|
|
54
|
+
| Lexical 0.49 + markdown/list/link/code nodes | 449 KB | **148 KB** |
|
|
55
|
+
| **This component** | — | **0 KB added** |
|
|
56
|
+
|
|
57
|
+
The full argument, including what the choice gives up and what would justify reversing it,
|
|
58
|
+
is in
|
|
59
|
+
[`docs/decisions/2026-08-19-the-markdown-editor-is-a-textarea-with-a-painted-layer.md`](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-19-the-markdown-editor-is-a-textarea-with-a-painted-layer.md).
|
|
60
|
+
|
|
61
|
+
The accessibility half of it is the half that is hard to buy back later. A `<textarea>`
|
|
62
|
+
arrives with caret movement, word-wise selection, `Home`/`End`, IME composition, spellcheck,
|
|
63
|
+
autocorrect, the platform's own undo stack, `role="textbox"` and `aria-multiline="true"` —
|
|
64
|
+
all of it, in every browser, tested by the vendors. A `contenteditable` starts with none of
|
|
65
|
+
those and re-earns each one, and the ones it never fully re-earns are IME and undo.
|
|
66
|
+
|
|
67
|
+
## The painted layer, and the geometry that constrains it
|
|
68
|
+
|
|
69
|
+
The surface is a real `<textarea>` with a painted layer behind it in the **same grid cell**.
|
|
70
|
+
The textarea owns the caret, the selection, the undo stack and the IME; the layer owns the
|
|
71
|
+
colour. Its text is transparent and its `caret-color` is not, which is what `caret-color`
|
|
72
|
+
exists for.
|
|
73
|
+
|
|
74
|
+
That only works while the two layers **lay out identically**, so:
|
|
75
|
+
|
|
76
|
+
- the tokenizer keeps **every character**. It never trims, never normalises a tab, never
|
|
77
|
+
drops an unterminated marker. `markdownSpans.test.ts` joins every span back together and
|
|
78
|
+
asserts it reproduces the source byte for byte;
|
|
79
|
+
- every span style is **metric-neutral**. Bold is faux bold — `text-shadow` at ±0.02em paints
|
|
80
|
+
the glyph twice, a hair apart, which thickens it and moves nothing. Italic is real, because
|
|
81
|
+
the surface is monospaced and a monospaced italic keeps the advance width by definition.
|
|
82
|
+
Headings get weight and colour rather than size. `MarkdownEditor.test.tsx` **measures** each
|
|
83
|
+
one against plain text and fails if any of them is wider.
|
|
84
|
+
|
|
85
|
+
The honest limit follows from the same constraint: **markers are dimmed, not hidden.**
|
|
86
|
+
Concealing `**` would change the line's width and there is no way to do it in this medium.
|
|
87
|
+
So "raw syntax on the cursor's line" becomes a contrast change — muted markers everywhere,
|
|
88
|
+
accent markers on the line the caret is on — and true concealment is the staged follow-up,
|
|
89
|
+
which needs a decoration-capable surface (CodeMirror 6) and would arrive as an **optional
|
|
90
|
+
subpath** so consumers that do not use it keep paying nothing.
|
|
91
|
+
|
|
92
|
+
<Canvas of={MarkdownEditorStories.NoUpload} />
|
|
93
|
+
|
|
94
|
+
## Uncontrolled, and that is not a shortcut
|
|
95
|
+
|
|
96
|
+
`defaultValue` in, `onChange` out — the same contract as `Select`, and here it protects
|
|
97
|
+
something specific. A controlled React `<textarea>` round-trips every keystroke through
|
|
98
|
+
render, and any programmatic `value` assignment **wipes the browser's undo stack**: Ctrl+Z
|
|
99
|
+
after a toolbar click then skips the paragraph you typed before it, or does nothing at all.
|
|
100
|
+
|
|
101
|
+
So the textarea is never handed a `value`. Its text is mirrored into state for *painting*
|
|
102
|
+
only, and every programmatic edit is applied as a range replacement through
|
|
103
|
+
`document.execCommand("insertText")` — deprecated, and still the only API that appends to
|
|
104
|
+
the native undo stack. `getMinimalEdit` is what turns a command's whole-string result back
|
|
105
|
+
into that range, so a bold toggle is **one** undo step over four characters rather than a
|
|
106
|
+
document-sized one.
|
|
107
|
+
|
|
108
|
+
A consumer that must swap the whole document — Docket opening a different task — remounts
|
|
109
|
+
with a `key`. That is one line, and it is honest about the fact that the old undo history
|
|
110
|
+
belonged to the old document.
|
|
111
|
+
|
|
112
|
+
## Paste an image at the cursor
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
<MarkdownEditor
|
|
116
|
+
label="Description"
|
|
117
|
+
onUploadImage={async (file) => {
|
|
118
|
+
const { url } = await uploadToBlobStore(file)
|
|
119
|
+
|
|
120
|
+
return { alt: file.name, url }
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**The component owns no storage.** It hands you a `File`, waits, and inserts the URL you
|
|
126
|
+
return as `` at the caret. Docket keeps its own blob storage precisely so the URL
|
|
127
|
+
stays stable and theirs.
|
|
128
|
+
|
|
129
|
+
While the request is in flight the document holds a **markdown placeholder** —
|
|
130
|
+
`` — rather than an overlay, because the user keeps typing
|
|
131
|
+
during an upload and the insertion point has to move with what they type. It carries a
|
|
132
|
+
counter so two files of the same name do not resolve into each other's slot, and a failed
|
|
133
|
+
upload leaves a document the user can read and fix rather than a dangling widget.
|
|
134
|
+
|
|
135
|
+
Omit `onUploadImage` and image paste is simply not offered. A `<textarea>` ignores a pasted
|
|
136
|
+
image on its own, which is the correct fallback rather than a broken one.
|
|
137
|
+
|
|
138
|
+
## Composition, and the one component that did not fit
|
|
139
|
+
|
|
140
|
+
The toolbar is this package's `Toolbar`, so the row collapses into a **menu by measurement**
|
|
141
|
+
rather than at a breakpoint — which is what the Narrow View actually needs, since a lane in a
|
|
142
|
+
three-up board is narrow on a 4K display. `Field` wraps it through `SlotProps`, so a label, a
|
|
143
|
+
description and an error wire themselves to the `<textarea>` rather than stopping at this
|
|
144
|
+
component.
|
|
145
|
+
|
|
146
|
+
`FileDropZone` does **not** wrap it, and that is the interesting one. That component is a
|
|
147
|
+
`<label>` around a file input covering its whole area — correct for "drop a disc image here",
|
|
148
|
+
and wrong for a text surface, where the drop has to land *at a character offset* and the area
|
|
149
|
+
has to keep taking clicks as caret placement. So the editor handles its own drop, and
|
|
150
|
+
`FileDropZone` stays the right answer for the attachment list beside it.
|
|
151
|
+
|
|
152
|
+
<Canvas of={MarkdownEditorStories.Responsive} />
|
|
153
|
+
|
|
154
|
+
## Keyboard
|
|
155
|
+
|
|
156
|
+
| Key | Does |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| `Ctrl`/`Cmd` + `B` | Bold, and un-bold |
|
|
159
|
+
| `Ctrl`/`Cmd` + `I` | Italic |
|
|
160
|
+
| `Ctrl`/`Cmd` + `E` | Inline code |
|
|
161
|
+
| `Ctrl`/`Cmd` + `K` | Link, caret parked in the destination |
|
|
162
|
+
| `Ctrl`/`Cmd` + `]` / `[` | Indent / outdent the selected lines |
|
|
163
|
+
| `Enter` | Continues a list, a task list or a quote — and **clears** an empty item |
|
|
164
|
+
| `Tab` | Moves focus. Always. |
|
|
165
|
+
|
|
166
|
+
**`Tab` is never captured**, at any time. Indenting with Tab is the convention in editors
|
|
167
|
+
that own the whole page, and inside a form it is a keyboard trap: WCAG 2.1.2 wants focus to
|
|
168
|
+
be able to leave every component with the standard gesture, and a multi-line field on a task
|
|
169
|
+
form is exactly where somebody tabs onward.
|
|
170
|
+
|
|
171
|
+
Enter returns *nothing to do* rather than an unchanged state when the caret is not in a list,
|
|
172
|
+
so the keypress falls through to the browser and lands on the native undo stack as an
|
|
173
|
+
ordinary typing step.
|
|
174
|
+
|
|
175
|
+
<Canvas of={MarkdownEditorStories.AllStates} />
|
|
176
|
+
|
|
177
|
+
## What this deliberately does not parse
|
|
178
|
+
|
|
179
|
+
The tokenizer resolves **one line at a time**, carrying exactly one bit of cross-line state:
|
|
180
|
+
whether we are inside a fenced code block. Painting re-runs on every keystroke, so the cost
|
|
181
|
+
has to be linear in the line rather than in the document — and a mis-coloured marker is
|
|
182
|
+
cosmetic while a mis-*placed* one is a broken editor.
|
|
183
|
+
|
|
184
|
+
So these colour as plain text: setext headings (`Title` over `=====`), indented four-space
|
|
185
|
+
code blocks, reference links (`[a][b]`), autolinks in angle brackets, and emphasis that spans
|
|
186
|
+
a line break. They all still **store** correctly, because the stored value is the textarea's
|
|
187
|
+
and not the tokenizer's.
|
|
188
|
+
|
|
189
|
+
<Canvas of={MarkdownEditorStories.Interactive} />
|