@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.
Files changed (113) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +33 -3
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +20 -2
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Board/Board.d.ts +119 -0
  7. package/dist/Board/Board.d.ts.map +1 -0
  8. package/dist/Board/Board.js +165 -0
  9. package/dist/Board/Board.js.map +1 -0
  10. package/dist/Board/BoardCard.d.ts +120 -0
  11. package/dist/Board/BoardCard.d.ts.map +1 -0
  12. package/dist/Board/BoardCard.js +77 -0
  13. package/dist/Board/BoardCard.js.map +1 -0
  14. package/dist/Board/BoardLaneList.d.ts +103 -0
  15. package/dist/Board/BoardLaneList.d.ts.map +1 -0
  16. package/dist/Board/BoardLaneList.js +69 -0
  17. package/dist/Board/BoardLaneList.js.map +1 -0
  18. package/dist/Board/boardMove.d.ts +116 -0
  19. package/dist/Board/boardMove.d.ts.map +1 -0
  20. package/dist/Board/boardMove.js +116 -0
  21. package/dist/Board/boardMove.js.map +1 -0
  22. package/dist/Board/useBoardDrag.d.ts +44 -0
  23. package/dist/Board/useBoardDrag.d.ts.map +1 -0
  24. package/dist/Board/useBoardDrag.js +237 -0
  25. package/dist/Board/useBoardDrag.js.map +1 -0
  26. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  27. package/dist/Checkbox/Checkbox.js +15 -4
  28. package/dist/Checkbox/Checkbox.js.map +1 -1
  29. package/dist/DataTable/DataTable.d.ts +159 -0
  30. package/dist/DataTable/DataTable.d.ts.map +1 -0
  31. package/dist/DataTable/DataTable.js +197 -0
  32. package/dist/DataTable/DataTable.js.map +1 -0
  33. package/dist/DatePicker/DateGrid.d.ts +79 -0
  34. package/dist/DatePicker/DateGrid.d.ts.map +1 -0
  35. package/dist/DatePicker/DateGrid.js +220 -0
  36. package/dist/DatePicker/DateGrid.js.map +1 -0
  37. package/dist/DatePicker/DatePicker.d.ts +138 -0
  38. package/dist/DatePicker/DatePicker.d.ts.map +1 -0
  39. package/dist/DatePicker/DatePicker.js +384 -0
  40. package/dist/DatePicker/DatePicker.js.map +1 -0
  41. package/dist/DatePicker/datePresets.d.ts +29 -0
  42. package/dist/DatePicker/datePresets.d.ts.map +1 -0
  43. package/dist/DatePicker/datePresets.js +28 -0
  44. package/dist/DatePicker/datePresets.js.map +1 -0
  45. package/dist/DatePicker/parseDateInput.d.ts +106 -0
  46. package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
  47. package/dist/DatePicker/parseDateInput.js +419 -0
  48. package/dist/DatePicker/parseDateInput.js.map +1 -0
  49. package/dist/DatePicker/plainDate.d.ts +167 -0
  50. package/dist/DatePicker/plainDate.d.ts.map +1 -0
  51. package/dist/DatePicker/plainDate.js +272 -0
  52. package/dist/DatePicker/plainDate.js.map +1 -0
  53. package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  54. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  55. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  56. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  57. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  58. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  60. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  62. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  64. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  65. package/dist/categoricalStyles.d.ts +53 -0
  66. package/dist/categoricalStyles.d.ts.map +1 -0
  67. package/dist/categoricalStyles.js +195 -0
  68. package/dist/categoricalStyles.js.map +1 -0
  69. package/dist/index.d.ts +22 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +11 -0
  72. package/dist/index.js.map +1 -1
  73. package/package.json +2 -2
  74. package/src/Badge/Badge.mdx +46 -0
  75. package/src/Badge/Badge.stories.tsx +98 -4
  76. package/src/Badge/Badge.test.tsx +157 -2
  77. package/src/Badge/Badge.tsx +49 -3
  78. package/src/Board/Board.mdx +199 -0
  79. package/src/Board/Board.stories.tsx +707 -0
  80. package/src/Board/Board.test.tsx +310 -0
  81. package/src/Board/Board.tsx +333 -0
  82. package/src/Board/BoardCard.tsx +290 -0
  83. package/src/Board/BoardLaneList.tsx +275 -0
  84. package/src/Board/boardMove.test.ts +213 -0
  85. package/src/Board/boardMove.ts +225 -0
  86. package/src/Board/useBoardDrag.ts +413 -0
  87. package/src/Checkbox/Checkbox.tsx +34 -3
  88. package/src/DataTable/DataTable.mdx +123 -0
  89. package/src/DataTable/DataTable.stories.tsx +411 -0
  90. package/src/DataTable/DataTable.test.tsx +386 -0
  91. package/src/DataTable/DataTable.tsx +497 -0
  92. package/src/DatePicker/DateGrid.tsx +467 -0
  93. package/src/DatePicker/DatePicker.mdx +170 -0
  94. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  95. package/src/DatePicker/DatePicker.test.tsx +455 -0
  96. package/src/DatePicker/DatePicker.tsx +852 -0
  97. package/src/DatePicker/datePresets.ts +34 -0
  98. package/src/DatePicker/parseDateInput.test.ts +187 -0
  99. package/src/DatePicker/parseDateInput.ts +629 -0
  100. package/src/DatePicker/plainDate.test.ts +375 -0
  101. package/src/DatePicker/plainDate.ts +465 -0
  102. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  103. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  104. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  105. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  106. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  107. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  108. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  109. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  110. package/src/argTypes.storyHelpers.ts +39 -0
  111. package/src/categoricalStyles.ts +244 -0
  112. package/src/index.ts +89 -0
  113. 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 `&lt;`", because the fixture
39
+ deliberately contains `&lt;` 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 `![alt](url)` 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
+ `![Uploading rack.png](#uploading-1)` — 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} />