@scalewing/react 1.55.0 → 1.57.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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +71 -4
  3. package/dist/components/DateToggleCalendar.d.ts +3 -1
  4. package/dist/components/DateToggleCalendar.js +4 -2
  5. package/dist/components/FixtureRow.d.ts +6 -4
  6. package/dist/components/FixtureRow.js +18 -20
  7. package/dist/components/ScheduleGrid.d.ts +3 -2
  8. package/dist/components/ScheduleGrid.js +21 -2
  9. package/dist/components/Table.d.ts +2 -2
  10. package/dist/components/Table.js +1 -1
  11. package/dist/components/date-toggle/types.d.ts +9 -2
  12. package/dist/components/fixture/FixtureScoreBoxes.d.ts +19 -0
  13. package/dist/components/fixture/FixtureScoreBoxes.js +57 -0
  14. package/dist/components/fixture/row-parts.d.ts +17 -0
  15. package/dist/components/fixture/row-parts.js +19 -0
  16. package/dist/components/fixture/score-digits.d.ts +7 -0
  17. package/dist/components/fixture/score-digits.js +9 -0
  18. package/dist/components/fixture/types.d.ts +80 -4
  19. package/dist/components/fixture/validate-score.d.ts +10 -0
  20. package/dist/components/fixture/validate-score.js +39 -0
  21. package/dist/components/fixture/validate.d.ts +6 -5
  22. package/dist/components/fixture/validate.js +17 -7
  23. package/dist/components/schedule-grid/labels.js +6 -1
  24. package/dist/components/schedule-grid/types.d.ts +10 -2
  25. package/dist/components/schedule-grid/use-schedule-carry.d.ts +5 -3
  26. package/dist/components/schedule-grid/use-schedule-carry.js +38 -16
  27. package/dist/components/table/TableRow.d.ts +18 -2
  28. package/dist/components/table/TableRow.js +4 -3
  29. package/dist/css/css-fixture-action.js +3 -3
  30. package/dist/css/css-fixture-detail.d.ts +2 -0
  31. package/dist/css/css-fixture-detail.js +44 -0
  32. package/dist/css/css-fixture-flag.js +12 -1
  33. package/dist/css/css-fixture-list.d.ts +1 -0
  34. package/dist/css/css-fixture-list.js +50 -0
  35. package/dist/css/css-fixture-score.d.ts +2 -0
  36. package/dist/css/css-fixture-score.js +97 -0
  37. package/dist/css/css-fixture-select.d.ts +2 -0
  38. package/dist/css/css-fixture-select.js +47 -0
  39. package/dist/css/css-fixture.js +16 -50
  40. package/dist/css/css-table-ledger.d.ts +4 -0
  41. package/dist/css/css-table-ledger.js +6 -0
  42. package/dist/css/css-table-row-rule.d.ts +2 -0
  43. package/dist/css/css-table-row-rule.js +22 -0
  44. package/dist/css/css-table.js +4 -0
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.js +1 -1
  47. package/dist/styles.css +125 -5
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.57.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9a42d28: `FixtureRow` `score` (opt-in, `FixtureScore`: `{ label, home, away, onChange(side, value), onSubmit(), onCancel?(), disabled?, error? }`, boxes `FixtureScoreBox`: `{ value, label, ref?, invalid? }`): two score boxes between the sides in place of `center` (now optional when `score` is given), digits only (`inputmode="numeric"`, two digits, selected on focus) in a group named by `label`, each box named by its own `label`; Enter calls `onSubmit`, Escape `onCancel`; 32 px squares on a fine pointer and 44 px on a coarse one; `error` is said politely under the row and marks the boxes invalid. A score row is never a link or a button, and works with `flag`, `select` and `action`. `FixtureRow` `detail` (opt-in): a full-width part under the row, outside its press area. `TableRow` `rule="after"` (opt-in, `TableRowRule`, `tableRowRules`): a 2 px muted rule under the row, such as a cut under the last playoff place, `CanvasText` in forced colors. New types `FixtureScore`, `FixtureScoreBox`, `FixtureScoreSide`, `TableRowRule`; new classes `sw-fixture-score`, `sw-fixture-score-box`, `sw-fixture-score-dash`, `sw-fixture-score-error`, `sw-fixture-item-below`, `sw-fixture-detail`, `sw-table-row-rule-after`.
8
+
9
+ ## 1.56.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 384520b: `FixtureRow` `select` (opt-in, `{ label, checked, onCheckedChange, disabled? }`): a leading checkbox with its label visually hidden, before the row and outside its press area, in the run's new first column (`sw-fixture-list` is now `auto minmax(0, 1fr) auto minmax(0, 1fr) auto auto auto`, six below `md`; the row spans `2 / -2`); the standard 24 px box, 44 px on a coarse pointer; works with `flag`, `action`, `href` and `onPress`. New type `FixtureSelect`; new classes `sw-fixture-item-select`, `sw-fixture-select`, `sw-fixture-row-flag-inline`. Fixes: below `md` a flagged row with no details' line keeps its flag inline; `ScheduleGrid` `labels.pickedUp` receives the input as a second argument (`ScheduleGridCarryMode`: `keyboard`, `tap`, `drag`; one-argument functions you pass still work, but a call to `defaultScheduleGridLabels.pickedUp` now needs the mode) and the default words fit it; a key or tap carry is cancelled when focus leaves the grid (a press on a header keeps focus and the carry); a drag release handled before the pick-up renders no longer leaves the item held; `DateToggleCalendar` `onChange` drops `value` entries outside `min`–`max` or on disabled days.
14
+
3
15
  ## 1.55.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -776,7 +776,7 @@ The layout follows the window, with generated media queries only. From 75rem (12
776
776
 
777
777
  A header `TableCell` (`as="th"`) with `sort` (`'ascending'`, `'descending'` or `'none'`) and `onSort` is its column's sort control: its children become a button in the header's own style with a chevron (before the name in a `numeric` or end-aligned column), and the cell carries `aria-sort` only while it is the sorted column. `sort` on a data cell, or one of the two without the other, throws a `TypeError`. `Table` `layout="fixed"` (default `auto`) sizes the columns from the header row alone, so a filter that hides a row moves no column, and `verticalAlign="top"` (default `middle`) starts every cell of a tall row on its first line. `TableCell` `width` is `min` (an auto table's column takes the least room its content allows, on one line; set it on each cell of the column) or a size, `xs` to `xl` (4, 6, 8, 12 and 16 rem); in a fixed table a column without a width shares what is left, so leave one column, such as a description, unsized. The HTML `width` attribute is not accepted. Unknown values throw a `RangeError`.
778
778
 
779
- A ledger read across as well as down takes `Table` `columnRules` (a hairline at the inline start of every cell after a row's first, mirrored right to left) and `TableCell` `tone` (`success`, `danger`, `muted` or `accent`: the cell's text in the theme colour, as `Text color` uses it; never the only signal, so keep the sign or the words). `TableRow` `variant="sub"` (default `"default"`) sets a row under the one above, as a batch's items: its row header, a `TableCell as="th" scope="row"`, is indented a `space-6` step with a guide bar at the indent (a sub row without a row header is not indented). `TableRow` `variant="total"` is a total mid-table, such as a subtotal the rows above add up to before the table goes on: its cells at weight 600 under a 2 px rule across its top (the cells' own collapsed top border, so forced colors keep it), as a `TableFooter` total is drawn, with no fill, so accent text in it keeps the contrast it has on the table's own surface. Name it with a row header and keep it in the body, after the rows it adds up (not the body's first row under a pinned header, whose rule would sit on its own); a cell's `tone` keeps its colour, and `struck` works on it. Its cells are all at weight 600, so a quiet link or a `TableCell onPress` action in it is no longer set apart by weight: keep a total row's name plain text (a `TableFooter` row is the same). `TableRow` `struck` keeps a voided row on the record: the subtle fill and the text muted and struck through, toned cells included; it works with any variant. A line-through is not read aloud, so say why in the row, such as a `Badge` "Voided": a Badge in a line of text is not struck, but one that is itself a flex item (a direct child of `Inline`) is, so put it in the title's line. A row header in the body or footer (`th scope="row"`) reads as body text. An unknown `tone` or `variant` throws a `RangeError`.
779
+ A ledger read across as well as down takes `Table` `columnRules` (a hairline at the inline start of every cell after a row's first, mirrored right to left) and `TableCell` `tone` (`success`, `danger`, `muted` or `accent`: the cell's text in the theme colour, as `Text color` uses it; never the only signal, so keep the sign or the words). `TableRow` `variant="sub"` (default `"default"`) sets a row under the one above, as a batch's items: its row header, a `TableCell as="th" scope="row"`, is indented a `space-6` step with a guide bar at the indent (a sub row without a row header is not indented). `TableRow` `variant="total"` is a total mid-table, such as a subtotal the rows above add up to before the table goes on: its cells at weight 600 under a 2 px rule across its top (the cells' own collapsed top border, so forced colors keep it), as a `TableFooter` total is drawn, with no fill, so accent text in it keeps the contrast it has on the table's own surface. Name it with a row header and keep it in the body, after the rows it adds up (not the body's first row under a pinned header, whose rule would sit on its own); a cell's `tone` keeps its colour, and `struck` works on it. Its cells are all at weight 600, so a quiet link or a `TableCell onPress` action in it is no longer set apart by weight: keep a total row's name plain text (a `TableFooter` row is the same). `TableRow` `struck` keeps a voided row on the record: the subtle fill and the text muted and struck through, toned cells included; it works with any variant. A line-through is not read aloud, so say why in the row, such as a `Badge` "Voided": a Badge in a line of text is not struck, but one that is itself a flex item (a direct child of `Inline`) is, so put it in the title's line. A row header in the body or footer (`th scope="row"`) reads as body text. `TableRow` `rule="after"` (opt-in, `TableRowRule`; `tableRowRules` lists the values) draws a rule under the row, such as the cut under the last playoff place: its cells' bottom border 2 px in the muted grey, stronger than the 1 px hairlines between rows (3:1 on the surface) without a colour's meaning, kept in `CanvasText` in forced colors and resolved over the next row's top by the collapsed-border model. The row's words, weight and fill are unchanged and the line is not read aloud, so say what it means in words beside the table ("The line under 4th place marks the playoff places"). It works with any variant, `struck` and `selected`; class `sw-table-row-rule-after`. An unknown `tone`, `variant` or `rule` throws a `RangeError`.
780
780
 
781
781
  `Table` `stickyHeader` pins the header: the whole `TableHeader` as one group, every row in it (the column headings and any row under them, such as a running total) with all its cells, `th` and `td`, each row under the one above, on the glass with its row and column rules. `true` (default) pins it to the top of the table's own scroll region, `false` lets it scroll away, and `'page'` pins it to the page's top as the page scrolls. With `'page'`, while the table fits its width its region stops being a scroll box (`sw-table-sticky-page`), so the header stays in view under any sticky `AppHeader` (which covers it); a table wider than its region scrolls sideways in it and pins inside it. Use it for a long table whose last row, such as a non-sticky `TableFooter` total, should scroll with the rows. An unknown value throws a `RangeError`.
782
782
 
@@ -1241,7 +1241,7 @@ const { headings, omissions } = inspectMarkdown(page.body, {
1241
1241
  </FixtureGroup>
1242
1242
  ```
1243
1243
 
1244
- `FixtureRow` `flag` (opt-in, `FixtureFlag`: `{ label, tone? }`) marks an issue at the row's start, such as a clash: a round mark a space-4 across with a "!", in the tone's tint and ink (`warning`, amber, the default, for something that needs the person; `danger`, red), its edge keeping 3:1 on the surface in every palette and the text colour in forced colors. The mark is `aria-hidden` with `label` as its `title` tooltip, and `label` is read first in the row from a visually hidden span ("Can't-play clash Heron Marsh 8:00 Otter Pool"); with `aria-label` it is put before your words with a comma ("Can't-play clash, Heron Marsh vs Otter Pool, 8:00"), so do not repeat it there. From `md` the mark sits on the first line at the row's start, the home side kept clear of it; below `md` it leads the second line, before the details, so it never squeezes the home name (a row with no details gains that line for it). A blank `label` or an unknown `tone` throws a `RangeError`. `action` (opt-in) is a control after the row, such as a small neutral `Button` ("Move", named in your words: `aria-label="Move Heron Marsh vs Otter Pool"`): it is the row's sibling in its list item, outside a link or button row's press area and after it in the Tab order, a press on it never opens the row, and it stays centred on the row's height in the run's last column at every width (from `md` the row's one line, below `md` both lines), so every row's action lines up. Where `:has` is supported the hover fill spans the row and its action. An empty slot draws nothing. The run gains a sixth column for it (`sw-fixture-list` is `minmax(0, 1fr) auto minmax(0, 1fr) auto auto auto`, five below `md`); a row spans every column but the action's. New classes `sw-fixture-row-flagged`, `sw-fixture-flag`, `sw-fixture-flag-mark`, `sw-fixture-flag-warning`, `sw-fixture-flag-danger`, `sw-fixture-item-action` and `sw-fixture-action`.
1244
+ `FixtureRow` `flag` (opt-in, `FixtureFlag`: `{ label, tone? }`) marks an issue at the row's start, such as a clash: a round mark a space-4 across with a "!", in the tone's tint and ink (`warning`, amber, the default, for something that needs the person; `danger`, red), its edge keeping 3:1 on the surface in every palette and the text colour in forced colors. The mark is `aria-hidden` with `label` as its `title` tooltip, and `label` is read first in the row from a visually hidden span ("Can't-play clash Heron Marsh 8:00 Otter Pool"); with `aria-label` it is put before your words with a comma ("Can't-play clash, Heron Marsh vs Otter Pool, 8:00"), so do not repeat it there. From `md` the mark sits on the first line at the row's start, the home side kept clear of it; below `md` it leads the second line, before the details, so it never squeezes the home name. A row with nothing on that line below `md` (no `status`, and no `meta` or one hidden below `md` by `metaHideBelow`) keeps the mark inline on its first line instead, as from `md` (class `sw-fixture-row-flag-inline`), rather than gaining a line that holds only the flag. A blank `label` or an unknown `tone` throws a `RangeError`. `action` (opt-in) is a control after the row, such as a small neutral `Button` ("Move", named in your words: `aria-label="Move Heron Marsh vs Otter Pool"`): it is the row's sibling in its list item, outside a link or button row's press area and after it in the Tab order, a press on it never opens the row, and it stays centred on the row's height in the run's last column at every width (from `md` the row's one line, below `md` both lines), so every row's action lines up. Where `:has` is supported the hover fill spans the row and its action. An empty slot draws nothing. The run has a column for it at the end, and one for `select` at the start (`sw-fixture-list` is `auto minmax(0, 1fr) auto minmax(0, 1fr) auto auto auto`, six below `md`); a row spans every column but those two (`grid-column: 2 / -2`), and an `auto` column no row uses takes no space. New classes `sw-fixture-row-flagged`, `sw-fixture-flag`, `sw-fixture-flag-mark`, `sw-fixture-flag-warning`, `sw-fixture-flag-danger`, `sw-fixture-item-action` and `sw-fixture-action`.
1245
1245
 
1246
1246
  ```tsx
1247
1247
  // move is your own handler, such as opening a Move dialog.
@@ -1264,7 +1264,74 @@ const { headings, omissions } = inspectMarkdown(page.body, {
1264
1264
  />
1265
1265
  ```
1266
1266
 
1267
- `ScheduleGrid` is items in a grid of rows (such as times) by columns (such as places), moved between cells. Props: `label` (the grid's name), `columns` and `rows` (`{ id, label }[]`, in order), `rowsLabel` (opt-in, the header over the rows' labels, "Time"; without it that corner is blank and `aria-hidden`), `items` (`{ id, columnId, rowId, label, content }[]`: `label` is the item's name in words, `content` what its card draws; hold no controls in it), `disabledCells` (opt-in, `{ columnId, rowId, reason }[]`: never a target, drawn on the quiet fill with the reason), `canDrop` (opt-in, `(itemId, target) => true | { reason }`, where `target` is `{ kind: 'move', columnId, rowId }` for an empty cell or `{ kind: 'swap', itemId, columnId, rowId }` for another item; omitted, every target is allowed), `onMove(itemId, { columnId, rowId })`, `onSwap(itemId, otherItemId)`, `disabled` (opt-in: nothing can be picked up; the cells still take focus) and `labels` (`Partial<ScheduleGridLabels>`, an `undefined` entry keeping the default; `defaultScheduleGridLabels` holds the English words: `open`, `movingFrom`, `releaseToMove`, `releaseToSwap`, `instructions`, and the announcements `pickedUp(item)`, `moved(item, row, column)`, `swapped(item, other)`, `cancelled(item)`). The grid holds no rules: `canDrop` decides every target and is asked for every cell while an item is carried (keep it cheap), and the grid moves nothing itself: `onMove` and `onSwap` report, and you update `items`. It is a `table` with `role="grid"` named by `label` and described by `labels.instructions` (visually hidden), its column and row headers `th`, and each cell a `gridcell` named by what it holds (the item's `label`, `labels.open` or a disabled cell's reason) and, while an item is carried, what it would do (", Moving from here", ", Release to move here", ", Release to swap", or ", " and the blocked reason); the drawing inside a cell (the card, its words and captions) is `aria-hidden`, so the name is read once. Cells with the same contents share a name, as the headers tell them apart for assistive technology: in a test, find one by its row (`within(getByRole('row', { name: /^10:30/ })).getAllByRole('gridcell')[1]`). The grid is one Tab stop with roving focus: the arrows move by a cell (the horizontal ones swapped in right-to-left), Home and End go to the row's ends and with Control (or ⌘) to the grid's first and last cells. On an item, M or Space picks it up; while carried, the arrows choose the target (the focused cell shows the accent ring and its caption), Enter or Space drops it there and Escape cancels and puts focus back on the item. A mouse drags (a press that moves 4 px; a pen too, where the browser does not pan the page under it): the cell under the pointer is the one hover target, and the release drops it; outside the grid it cancels. Touch never drags, so a finger still scrolls the page: a tap on an item picks it up and a tap on a cell drops it; a tap on its own cell, or a press anywhere outside the grid, cancels. A mouse may use the same press-then-press path (WCAG 2.5.7). Dropping on an empty allowed cell calls `onMove`, on another allowed item `onSwap`, on its own cell cancels; a blocked or disabled target is refused with its reason announced (a key or tap carry keeps the item to try elsewhere, a drag ends). Each step (picked up, moved, swapped, cancelled, refused) is said in a polite `role="status"` live region inside the grid, a repeat said again. While an item is carried its own cell takes a dashed edge and "Moving from here", every cell `canDrop` blocks shows its reason in the warning ink with a warning edge (an open one on the warning tint), the target has the accent ring (an open one on the accent tint), and disabled cells keep their grey look; only open cells are filled, so an item's card stays on the surface its words were made for. Columns share the width equally, each at least the `md` table column (8rem), the rows' labels the `sm` one; when they do not fit, the grid scrolls sideways inside its own box, never the page. Cells are at least 44 px plus a space-3 high. Forced colors keep every edge (a blocked one dotted) and draw rings in Highlight. Classes `sw-schedule-grid*` (see the catalog). A blank `label`, `rowsLabel` or word in `labels`, a message in `labels` that is not a function, no columns or rows, a blank or repeated id or label, an item or disabled cell outside the grid, two items in one cell, a cell disabled twice, a disabled cell that holds an item or has no reason, or a `canDrop` reason that is blank throws a `RangeError`.
1267
+ `FixtureRow` `select` (opt-in, `FixtureSelect`: `{ label, checked, onCheckedChange, disabled? }`) is a leading checkbox that chooses the row, such as games picked to postpone. It is a `Checkbox` with its `label` visually hidden ("Select Heron Marsh vs Pine Ridge"; name it by the words the row shows), the row's sibling in its list item before the row: outside a link or button row's press area, before it in the Tab order, and a press on it (or Space) calls `onCheckedChange` and never opens the row. The box is the standard 24 px `Checkbox` box, its own target on a fine pointer and 44 × 44 px on a coarse one (the row's start padding dropped there, as the target pads the box); it is centred on the row's height in the run's first column at every width, so the boxes line up down the run. `disabled` keeps it shown and unchanged. It works with `flag` (the box comes first, then the mark), `action`, `href` and `onPress`, and the hover fill spans the whole item where `:has` is supported. The run's columns are shared, so on a phone a select, side marks, a chevron and an action in one run leave the names little room: leave the marks out or hide `meta` there. A `select` without a nonempty `label`, a boolean `checked` or an `onCheckedChange` throws a `RangeError`. New type `FixtureSelect`; new classes `sw-fixture-item-select`, `sw-fixture-select` and `sw-fixture-row-flag-inline`.
1268
+
1269
+ ```tsx
1270
+ // chosen, choose and pickTime are your own state and handlers.
1271
+ <FixtureRow
1272
+ home={{ name: 'Heron Marsh' }}
1273
+ away={{ name: 'Pine Ridge' }}
1274
+ center="8:00"
1275
+ select={{
1276
+ label: 'Select Heron Marsh vs Pine Ridge',
1277
+ checked: chosen.has('g1'),
1278
+ onCheckedChange: (checked) => choose('g1', checked),
1279
+ }}
1280
+ action={
1281
+ <Button
1282
+ aria-label="Pick a time for Heron Marsh vs Pine Ridge"
1283
+ onPress={pickTime}
1284
+ size="sm"
1285
+ variant="neutral"
1286
+ >
1287
+ Pick a time
1288
+ </Button>
1289
+ }
1290
+ />
1291
+ ```
1292
+
1293
+ `FixtureRow` `score` (opt-in, `FixtureScore`) puts two score boxes between the sides in place of `center`, for typing a result: `{ label, home, away, onChange(side, value), onSubmit(), onCancel?(), disabled?, error? }`, each box a `FixtureScoreBox` `{ value, label, ref?, invalid? }`. The boxes sit in a `role="group"` named by `label` ("Score, Heron Marsh v Otter Pool"), each a text input named by its own `label` (the side's name), home first, with a hidden en dash between them; none of those words is drawn, so name them in the consumer's language. Each box is `inputmode="numeric"`, `pattern="[0-9]*"`, `maxLength` 2 and `autocomplete="off"`; typed or pasted non-digits are dropped before `onChange` is called with the side (`'home'` or `'away'`) and the box's digits, and a change that keeps the same digits is not reported. Focus selects a box's text, so a typed score replaces it. Enter in either box calls `onSubmit` (not while an IME composition is open) and Escape calls `onCancel`, which should put the boxes back, such as to the saved score (Escape is prevented then, so an enclosing `Dialog` stays open; without `onCancel` it is left alone). `disabled` shows both boxes locked, with the canvas's disabled look. `error` ("Type both scores.") is said under the row in a polite live region in caption type and the danger ink, from the home side's start; it describes both boxes (`aria-describedby`) and marks them `aria-invalid` with a danger edge (`Mark` in forced colors), and a box's `invalid` (`true` or `false`) overrides that for the box, so a filled box can stay unmarked; an empty `error` shows nothing. `ref` reaches a box's input, such as to focus the home box of the row a link opened or the next game's after a save. The boxes are squares the sm control height (32 px) on a fine pointer, so the row keeps its 44 px height, and the 44 px touch target on a coarse one; below `md` their margin is a space-2 step. A row with `score` is never a link or a button (its boxes are their own controls), and it works with `flag`, `select`, `action`, `meta`, `status` and `detail`; the Tab order is the select, the home box, the away box, anything in `detail`, then the action. On a touch screen the numeric keypad may have no Return key (iOS), so give touch users a visible action, such as a small neutral "Record score" `Button` in `action`. With neither `center` nor `score`, with both, with `centerKind` beside `score`, with `href` or `onPress` beside `score`, or with a score lacking a nonempty `label`, boxes with a string `value` and a nonempty `label`, an `onChange` or an `onSubmit` (or with an `onCancel` that is not a function or an `error` that is not a string), it throws a `RangeError`. `center` is optional when `score` is given. New types `FixtureScore`, `FixtureScoreBox`, `FixtureScoreSide`; new classes `sw-fixture-score`, `sw-fixture-score-box`, `sw-fixture-score-dash`, `sw-fixture-score-error`.
1294
+
1295
+ `FixtureRow` `detail` (opt-in, `ReactNode`) is a full-width part under the row, such as a reason field while an official score is changed: in the row's list item after the row (and after a score's message), outside a link or button row's press area, and before the action in the Tab order (so a reason field comes before a Record button), spanning the run's columns from the home side's start (after a `select`'s box) to the action's end, with the row's inline padding and a space-2 step below. An empty slot draws nothing. New classes `sw-fixture-item-below` (an item with a score or a detail) and `sw-fixture-detail`.
1296
+
1297
+ ```tsx
1298
+ // tally, missing, change, record, reset, reason, setReason and homeBox are your own.
1299
+ <FixtureRow
1300
+ home={{ name: 'Heron Marsh' }}
1301
+ away={{ name: 'Otter Pool' }}
1302
+ score={{
1303
+ label: 'Score, Heron Marsh v Otter Pool',
1304
+ home: { value: tally.home, label: 'Heron Marsh', ref: homeBox },
1305
+ away: { value: tally.away, label: 'Otter Pool' },
1306
+ onChange: change,
1307
+ onSubmit: record,
1308
+ onCancel: reset,
1309
+ error: missing ? 'Type both scores.' : undefined,
1310
+ }}
1311
+ meta="Changing the official 0–0"
1312
+ action={
1313
+ <Button
1314
+ aria-label="Record Heron Marsh vs Otter Pool"
1315
+ onPress={record}
1316
+ size="sm"
1317
+ variant="neutral"
1318
+ >
1319
+ Record score
1320
+ </Button>
1321
+ }
1322
+ detail={
1323
+ <Field label="Reason" labelVisuallyHidden>
1324
+ <input
1325
+ onChange={(event) => setReason(event.currentTarget.value)}
1326
+ placeholder="Why?"
1327
+ value={reason}
1328
+ />
1329
+ </Field>
1330
+ }
1331
+ />
1332
+ ```
1333
+
1334
+ `ScheduleGrid` is items in a grid of rows (such as times) by columns (such as places), moved between cells. Props: `label` (the grid's name), `columns` and `rows` (`{ id, label }[]`, in order), `rowsLabel` (opt-in, the header over the rows' labels, "Time"; without it that corner is blank and `aria-hidden`), `items` (`{ id, columnId, rowId, label, content }[]`: `label` is the item's name in words, `content` what its card draws; hold no controls in it), `disabledCells` (opt-in, `{ columnId, rowId, reason }[]`: never a target, drawn on the quiet fill with the reason), `canDrop` (opt-in, `(itemId, target) => true | { reason }`, where `target` is `{ kind: 'move', columnId, rowId }` for an empty cell or `{ kind: 'swap', itemId, columnId, rowId }` for another item; omitted, every target is allowed), `onMove(itemId, { columnId, rowId })`, `onSwap(itemId, otherItemId)`, `disabled` (opt-in: nothing can be picked up; the cells still take focus) and `labels` (`Partial<ScheduleGridLabels>`, an `undefined` entry keeping the default; `defaultScheduleGridLabels` holds the English words: `open`, `movingFrom`, `releaseToMove`, `releaseToSwap`, `instructions`, and the announcements `pickedUp(item, mode)`, `moved(item, row, column)`, `swapped(item, other)`, `cancelled(item)`). The grid holds no rules: `canDrop` decides every target and is asked for every cell while an item is carried (keep it cheap), and the grid moves nothing itself: `onMove` and `onSwap` report, and you update `items`. It is a `table` with `role="grid"` named by `label` and described by `labels.instructions` (visually hidden), its column and row headers `th`, and each cell a `gridcell` named by what it holds (the item's `label`, `labels.open` or a disabled cell's reason) and, while an item is carried, what it would do (", Moving from here", ", Release to move here", ", Release to swap", or ", " and the blocked reason); the drawing inside a cell (the card, its words and captions) is `aria-hidden`, so the name is read once. Cells with the same contents share a name, as the headers tell them apart for assistive technology: in a test, find one by its row (`within(getByRole('row', { name: /^10:30/ })).getAllByRole('gridcell')[1]`). The grid is one Tab stop with roving focus: the arrows move by a cell (the horizontal ones swapped in right-to-left), Home and End go to the row's ends and with Control (or ⌘) to the grid's first and last cells. On an item, M or Space picks it up; while carried, the arrows choose the target (the focused cell shows the accent ring and its caption), Enter or Space drops it there and Escape cancels and puts focus back on the item. A mouse drags (a press that moves 4 px; a pen too, where the browser does not pan the page under it): the cell under the pointer is the one hover target, and the release drops it; outside the grid it cancels. Touch never drags, so a finger still scrolls the page: a tap on an item picks it up and a tap on a cell drops it; a tap on its own cell, or a press anywhere outside the grid, cancels. A mouse may use the same press-then-press path (WCAG 2.5.7). Dropping on an empty allowed cell calls `onMove`, on another allowed item `onSwap`, on its own cell cancels; a blocked or disabled target is refused with its reason announced (a key or tap carry keeps the item to try elsewhere, a drag ends). Each step (picked up, moved, swapped, cancelled, refused) is said in a polite `role="status"` live region inside the grid, a repeat said again. `pickedUp` is given how the item was picked up as its second argument (`ScheduleGridCarryMode`: `'keyboard'`, `'tap'` for a press then a press, or `'drag'`), and the default words fit it ("Choose a cell with the arrow keys…" only for keys; "Press a cell to drop it there, or press outside the grid to cancel." for a tap; "Release it on a cell to drop it there." for a drag); a one-argument function still works. A carry by keys or taps is also cancelled (and announced) when focus leaves the grid, such as a Tab past it, but not when the window itself loses focus; a press on a row or column header keeps focus where it was, so it never cancels a carry. A drag's release always ends it, even one that arrives before the pick-up has rendered: it drops on the cell under it, or cancels outside the grid. While an item is carried its own cell takes a dashed edge and "Moving from here", every cell `canDrop` blocks shows its reason in the warning ink with a warning edge (an open one on the warning tint), the target has the accent ring (an open one on the accent tint), and disabled cells keep their grey look; only open cells are filled, so an item's card stays on the surface its words were made for. Columns share the width equally, each at least the `md` table column (8rem), the rows' labels the `sm` one; when they do not fit, the grid scrolls sideways inside its own box, never the page. Cells are at least 44 px plus a space-3 high. Forced colors keep every edge (a blocked one dotted) and draw rings in Highlight. Classes `sw-schedule-grid*` (see the catalog). A blank `label`, `rowsLabel` or word in `labels`, a message in `labels` that is not a function, no columns or rows, a blank or repeated id or label, an item or disabled cell outside the grid, two items in one cell, a cell disabled twice, a disabled cell that holds an item or has no reason, or a `canDrop` reason that is blank throws a `RangeError`.
1268
1335
 
1269
1336
  ```tsx
1270
1337
  // TalkCard, napping, move and swap are your own component and functions.
@@ -1299,7 +1366,7 @@ const { headings, omissions } = inspectMarkdown(page.body, {
1299
1366
  />
1300
1367
  ```
1301
1368
 
1302
- `DateToggleCalendar` is month grids where each day that can be toggled is a toggle button, such as the days off in a season. Props: `label` (names its `group`), `min` and `max` (`YYYY-MM-DD`: the first and last day that can be toggled; the months shown run from `min`'s month to `max`'s, at most 24), `value` (the days on, `YYYY-MM-DD`, in any order) and `onChange` (the days on after a press, sorted and without repeats), `isDateDisabled` (opt-in, `(date) => boolean`, days between the bounds that cannot be toggled, such as days with no games), `toggledLabel` (opt-in, words read after a day that is on, "day off"), `locale` (a BCP 47 tag; by default the nearest `lang`, then en-US), `weekStartsOn` (0 Sunday, the default, or 1 Monday) and `disabled` (opt-in: the days keep focus, `aria-disabled`, and presses change nothing). Each month is a `grid` named by its title ("April 2027") with weekday column headers; a day that can be toggled is a `button` with `aria-pressed`, named by its full date ("Sunday, April 4, 2027", then ", day off" while on); a day outside the bounds or disabled is its muted number in a `gridcell` that is `aria-disabled` and named by its date, not a control; neighbouring months' days are blank. The whole calendar is one Tab stop: the first day on, else the first day that can be toggled, then wherever focus last was. The arrows move between days that can be toggled, skipping the rest: left and right to the previous or next one (swapped in right-to-left), up and down by weeks to the nearest one on the same weekday; Home and End go to the month's first and last, PageUp and PageDown to the first of the nearest earlier or later month that has one; a key at an edge does nothing and keeps the page still. Space and Enter toggle (the native button). A day is DateField's 44 px round square: a day that is on takes the accent fill and onAccent figures at weight 600 (Highlight in forced colors), hover the quiet fill on a day that is off. The months sit side by side in as many columns as fit, each seven days wide, and stack on a phone. Classes `sw-date-toggle*`. A blank `label` or `toggledLabel`, a bound or value that is not a `YYYY-MM-DD` date, `min` after `max`, more than 24 months, or an unknown `weekStartsOn` throws a `RangeError`.
1369
+ `DateToggleCalendar` is month grids where each day that can be toggled is a toggle button, such as the days off in a season. Props: `label` (names its `group`), `min` and `max` (`YYYY-MM-DD`: the first and last day that can be toggled; the months shown run from `min`'s month to `max`'s, at most 24), `value` (the days on, `YYYY-MM-DD`, in any order) and `onChange` (the days on after a press, sorted and without repeats, holding only days that can be toggled: a `value` entry outside `min`–`max` or on a day `isDateDisabled` refuses is not drawn and is dropped from the next `onChange`, so keep such days elsewhere if you need them), `isDateDisabled` (opt-in, `(date) => boolean`, days between the bounds that cannot be toggled, such as days with no games), `toggledLabel` (opt-in, words read after a day that is on, "day off"), `locale` (a BCP 47 tag; by default the nearest `lang`, then en-US), `weekStartsOn` (0 Sunday, the default, or 1 Monday) and `disabled` (opt-in: the days keep focus, `aria-disabled`, and presses change nothing). Each month is a `grid` named by its title ("April 2027") with weekday column headers; a day that can be toggled is a `button` with `aria-pressed`, named by its full date ("Sunday, April 4, 2027", then ", day off" while on); a day outside the bounds or disabled is its muted number in a `gridcell` that is `aria-disabled` and named by its date, not a control; neighbouring months' days are blank. The whole calendar is one Tab stop: the first day on, else the first day that can be toggled, then wherever focus last was. The arrows move between days that can be toggled, skipping the rest: left and right to the previous or next one (swapped in right-to-left), up and down by weeks to the nearest one on the same weekday; Home and End go to the month's first and last, PageUp and PageDown to the first of the nearest earlier or later month that has one; a key at an edge does nothing and keeps the page still. Space and Enter toggle (the native button). A day is DateField's 44 px round square: a day that is on takes the accent fill and onAccent figures at weight 600 (Highlight in forced colors), hover the quiet fill on a day that is off. The months sit side by side in as many columns as fit, each seven days wide, and stack on a phone. Classes `sw-date-toggle*`. A blank `label` or `toggledLabel`, a bound or value that is not a `YYYY-MM-DD` date, `min` after `max`, more than 24 months, or an unknown `weekStartsOn` throws a `RangeError`.
1303
1370
 
1304
1371
  ```tsx
1305
1372
  // surveyDays (a Set of YYYY-MM-DD days that can be toggled), daysOff and setDaysOff are your own.
@@ -4,6 +4,8 @@ export type { DateToggleCalendarProps } from './date-toggle/types.js';
4
4
  * Month grids from `min`'s month to `max`'s, where each day that can be
5
5
  * toggled is a toggle button, such as days off in a season. One Tab stop;
6
6
  * the arrow keys move between days that can be toggled, skipping the rest.
7
- * Invalid props throw a `RangeError`.
7
+ * `onChange` hands back only days that can be toggled: `value` entries
8
+ * outside `min`–`max` or on a disabled day are dropped. Invalid props throw
9
+ * a `RangeError`.
8
10
  */
9
11
  export declare function DateToggleCalendar(props: DateToggleCalendarProps): import("react").JSX.Element;
@@ -11,7 +11,9 @@ import { assertDateToggleCalendar } from './date-toggle/validate.js';
11
11
  * Month grids from `min`'s month to `max`'s, where each day that can be
12
12
  * toggled is a toggle button, such as days off in a season. One Tab stop;
13
13
  * the arrow keys move between days that can be toggled, skipping the rest.
14
- * Invalid props throw a `RangeError`.
14
+ * `onChange` hands back only days that can be toggled: `value` entries
15
+ * outside `min`–`max` or on a disabled day are dropped. Invalid props throw
16
+ * a `RangeError`.
15
17
  */
16
18
  export function DateToggleCalendar(props) {
17
19
  assertDateToggleCalendar(props);
@@ -47,5 +49,5 @@ export function DateToggleCalendar(props) {
47
49
  requestFocus();
48
50
  setChosenFocus(target);
49
51
  }
50
- return (_jsx("div", { "aria-label": label, className: "sw-date-toggle", onKeyDown: onKeyDown, ref: containerRef, role: "group", ...focusHandlers, children: months.map((month) => (_jsx(ToggleMonth, { disabled: disabled, focusDate: focusDate, isEnabled: isEnabled, locale: locale, month: month, onFocusDate: setChosenFocus, onToggle: (date) => onChange(toggleDate(value, date)), toggled: toggled, toggledLabel: toggledLabel, weekStartsOn: weekStartsOn }, `${month.year}-${month.month}`))) }));
52
+ return (_jsx("div", { "aria-label": label, className: "sw-date-toggle", onKeyDown: onKeyDown, ref: containerRef, role: "group", ...focusHandlers, children: months.map((month) => (_jsx(ToggleMonth, { disabled: disabled, focusDate: focusDate, isEnabled: isEnabled, locale: locale, month: month, onFocusDate: setChosenFocus, onToggle: (date) => onChange(toggleDate(value, date).filter(isEnabled)), toggled: toggled, toggledLabel: toggledLabel, weekStartsOn: weekStartsOn }, `${month.year}-${month.month}`))) }));
51
53
  }
@@ -1,12 +1,14 @@
1
1
  import { type FixtureRowProps } from './fixture/types.js';
2
- export type { FixtureFlag, FixtureRowProps, FixtureSide, FixtureStatus, } from './fixture/types.js';
2
+ export type { FixtureFlag, FixtureRowProps, FixtureScore, FixtureScoreBox, FixtureScoreSide, FixtureSelect, FixtureSide, FixtureStatus, } from './fixture/types.js';
3
3
  /**
4
4
  * One fixture in a FixtureGroup: the home side ending at the centre, the
5
5
  * kickoff time or score in the middle, the away side starting from it, then
6
6
  * the details and a status at the end (under the sides below `md`). With
7
7
  * `href` or `onPress` the whole row is a link or a button with a chevron.
8
- * `flag` marks an issue at the row's start; `action` is a control after the
9
- * row, outside its press area.
10
- * Invalid words or props throw a `RangeError`.
8
+ * `flag` marks an issue at the row's start; `select` is a checkbox before
9
+ * the row and `action` a control after it, both outside its press area.
10
+ * `score` puts two score boxes in the middle in place of `center`, and
11
+ * `detail` is a part under the row. Invalid words or props throw a
12
+ * `RangeError`.
11
13
  */
12
14
  export declare function FixtureRow(props: FixtureRowProps): import("react").JSX.Element;
@@ -1,45 +1,43 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from 'react';
3
4
  import { cx } from '../class-names.js';
4
5
  import { hideClass } from '../css/breakpoints.js';
5
6
  import { tabStop } from '../tab-stop.js';
6
7
  import { Badge } from './Badge.js';
7
8
  import { useFixtureList } from './fixture/fixture-list-context.js';
9
+ import { FixtureScoreBoxes, FixtureScoreError, } from './fixture/FixtureScoreBoxes.js';
10
+ import { Flag, Select, Side } from './fixture/row-parts.js';
8
11
  import { assertFixtureRow } from './fixture/validate.js';
9
12
  import { plainPress } from './link-press.js';
10
13
  import { hasSlot } from './slot.js';
11
- /**
12
- * The issue marker at the row's start: a drawn mark (its tooltip the
13
- * label) and the label in hidden words, read first in the row's name.
14
- */
15
- function Flag({ flag }) {
16
- return (_jsxs("span", { className: `sw-fixture-flag sw-fixture-flag-${flag.tone ?? 'warning'}`, children: [_jsx("span", { "aria-hidden": "true", className: "sw-fixture-flag-mark", title: flag.label }), _jsx("span", { className: "sw-sr-only", children: flag.label })] }));
17
- }
18
- /** One side: its name, and its mark on the centre's side (hidden). */
19
- function Side({ side, place }) {
20
- const mark = hasSlot(side.mark) ? (_jsx("span", { "aria-hidden": "true", className: "sw-fixture-mark", children: side.mark })) : null;
21
- return (_jsxs("span", { className: `sw-fixture-team sw-fixture-team-${place}`, children: [place === 'away' ? mark : null, _jsx("span", { className: "sw-fixture-name", children: side.name }), place === 'home' ? mark : null] }));
22
- }
23
14
  /**
24
15
  * One fixture in a FixtureGroup: the home side ending at the centre, the
25
16
  * kickoff time or score in the middle, the away side starting from it, then
26
17
  * the details and a status at the end (under the sides below `md`). With
27
18
  * `href` or `onPress` the whole row is a link or a button with a chevron.
28
- * `flag` marks an issue at the row's start; `action` is a control after the
29
- * row, outside its press area.
30
- * Invalid words or props throw a `RangeError`.
19
+ * `flag` marks an issue at the row's start; `select` is a checkbox before
20
+ * the row and `action` a control after it, both outside its press area.
21
+ * `score` puts two score boxes in the middle in place of `center`, and
22
+ * `detail` is a part under the row. Invalid words or props throw a
23
+ * `RangeError`.
31
24
  */
32
25
  export function FixtureRow(props) {
33
26
  useFixtureList();
34
27
  assertFixtureRow(props);
35
- const { action, away, center, centerKind = 'time', flag, home, href, meta, metaHideBelow, onPress, status, 'aria-label': ariaLabel, } = props;
28
+ const errorId = useId();
29
+ const { action, away, center, centerKind = 'time', detail, flag, home, href, meta, metaHideBelow, onPress, score, select, status, 'aria-label': ariaLabel, } = props;
36
30
  const hasMeta = hasSlot(meta);
37
31
  const pressable = href !== undefined || onPress !== undefined;
38
32
  const hasAction = hasSlot(action);
39
- const rowClass = cx('sw-fixture-row', pressable && 'sw-fixture-row-pressable', flag && 'sw-fixture-row-flagged');
33
+ const hasDetail = hasSlot(detail);
34
+ // Below md the end is a second line; with nothing on it (no status, and
35
+ // meta absent or hidden below md) the flag stays on the first line.
36
+ const endShownNarrow = status !== undefined || (hasMeta && metaHideBelow === undefined);
37
+ const rowClass = cx('sw-fixture-row', pressable && 'sw-fixture-row-pressable', flag && 'sw-fixture-row-flagged', flag && !endShownNarrow && 'sw-fixture-row-flag-inline');
40
38
  const name = ariaLabel !== undefined && flag ? `${flag.label}, ${ariaLabel}` : ariaLabel;
41
39
  // Spaces as their own text nodes keep the parts apart in the name.
42
- const parts = (_jsxs(_Fragment, { children: [flag ? (_jsxs(_Fragment, { children: [_jsx(Flag, { flag: flag }), ' '] })) : null, _jsx(Side, { place: "home", side: home }), ' ', _jsx("span", { className: `sw-fixture-center sw-fixture-center-${centerKind}`, children: center }), ' ', _jsx(Side, { place: "away", side: away }), hasMeta || status ? (_jsxs(_Fragment, { children: [' ', _jsxs("span", { className: "sw-fixture-end", children: [hasMeta ? (_jsx("span", { className: cx('sw-fixture-meta', metaHideBelow && hideClass('below', metaHideBelow)), children: meta })) : null, hasMeta && status ? ' ' : null, status ? (_jsx(Badge, { size: "sm", tone: status.tone, children: status.label })) : null] })] })) : null, pressable ? (_jsx("span", { "aria-hidden": "true", className: "sw-fixture-chevron" })) : null] }));
40
+ const parts = (_jsxs(_Fragment, { children: [flag ? (_jsxs(_Fragment, { children: [_jsx(Flag, { flag: flag }), ' '] })) : null, _jsx(Side, { place: "home", side: home }), ' ', score ? (_jsx(FixtureScoreBoxes, { errorId: errorId, score: score })) : (_jsx("span", { className: `sw-fixture-center sw-fixture-center-${centerKind}`, children: center })), ' ', _jsx(Side, { place: "away", side: away }), hasMeta || status ? (_jsxs(_Fragment, { children: [' ', _jsxs("span", { className: "sw-fixture-end", children: [hasMeta ? (_jsx("span", { className: cx('sw-fixture-meta', metaHideBelow && hideClass('below', metaHideBelow)), children: meta })) : null, hasMeta && status ? ' ' : null, status ? (_jsx(Badge, { size: "sm", tone: status.tone, children: status.label })) : null] })] })) : null, pressable ? (_jsx("span", { "aria-hidden": "true", className: "sw-fixture-chevron" })) : null] }));
43
41
  let row;
44
42
  if (href !== undefined)
45
43
  row = (_jsx("a", { "aria-label": name, className: rowClass, href: href, onClick: onPress
@@ -54,5 +52,5 @@ export function FixtureRow(props) {
54
52
  row = (_jsx("button", { "aria-label": name, className: rowClass, onClick: onPress, tabIndex: tabStop, type: "button", children: parts }));
55
53
  else
56
54
  row = _jsx("div", { className: rowClass, children: parts });
57
- return (_jsxs("li", { className: cx('sw-fixture-item', hasAction && 'sw-fixture-item-action'), children: [row, hasAction ? _jsx("span", { className: "sw-fixture-action", children: action }) : null] }));
55
+ return (_jsxs("li", { className: cx('sw-fixture-item', select && 'sw-fixture-item-select', hasAction && 'sw-fixture-item-action', (score || hasDetail) && 'sw-fixture-item-below'), children: [select ? _jsx(Select, { select: select }) : null, row, score ? _jsx(FixtureScoreError, { error: score.error, id: errorId }) : null, hasDetail ? _jsx("div", { className: "sw-fixture-detail", children: detail }) : null, hasAction ? _jsx("span", { className: "sw-fixture-action", children: action }) : null] }));
58
56
  }
@@ -1,11 +1,12 @@
1
1
  import { type ScheduleGridProps } from './schedule-grid/types.js';
2
2
  export { defaultScheduleGridLabels } from './schedule-grid/labels.js';
3
- export type { ScheduleGridAxisEntry, ScheduleGridDisabledCell, ScheduleGridDropCheck, ScheduleGridItem, ScheduleGridLabels, ScheduleGridProps, ScheduleGridSlot, ScheduleGridTarget, } from './schedule-grid/types.js';
3
+ export type { ScheduleGridAxisEntry, ScheduleGridCarryMode, ScheduleGridDisabledCell, ScheduleGridDropCheck, ScheduleGridItem, ScheduleGridLabels, ScheduleGridProps, ScheduleGridSlot, ScheduleGridTarget, } from './schedule-grid/types.js';
4
4
  /**
5
5
  * Items in a grid of rows (such as times) by columns (such as fields),
6
6
  * moved between cells: a mouse drag with one hover target, a press then a
7
7
  * press (touch), or keys (M or Space picks up, arrows choose, Enter drops,
8
- * Escape cancels). Dropping on another item swaps the two. The consumer's
8
+ * Escape cancels). A carry by keys or presses is cancelled when focus or a
9
+ * press leaves the grid. Dropping on another item swaps the two. The consumer's
9
10
  * `canDrop` decides every target; each step is announced politely. Invalid
10
11
  * props throw a `RangeError`.
11
12
  */
@@ -14,7 +14,8 @@ export { defaultScheduleGridLabels } from './schedule-grid/labels.js';
14
14
  * Items in a grid of rows (such as times) by columns (such as fields),
15
15
  * moved between cells: a mouse drag with one hover target, a press then a
16
16
  * press (touch), or keys (M or Space picks up, arrows choose, Enter drops,
17
- * Escape cancels). Dropping on another item swaps the two. The consumer's
17
+ * Escape cancels). A carry by keys or presses is cancelled when focus or a
18
+ * press leaves the grid. Dropping on another item swaps the two. The consumer's
18
19
  * `canDrop` decides every target; each step is announced politely. Invalid
19
20
  * props throw a `RangeError`.
20
21
  */
@@ -69,6 +70,24 @@ export function ScheduleGrid(props) {
69
70
  document.addEventListener('pointerdown', onDown);
70
71
  return () => document.removeEventListener('pointerdown', onDown);
71
72
  }, [cancel, carry]);
73
+ // Focus leaving the grid puts a key or press carry back; the window
74
+ // losing focus (another app, a devtools click) does not.
75
+ function onBlur(event) {
76
+ if (!carry || carry.mode === 'drag')
77
+ return;
78
+ const next = event.relatedTarget;
79
+ if (next instanceof Node && event.currentTarget.contains(next))
80
+ return;
81
+ if (next === null && !document.hasFocus())
82
+ return;
83
+ cancel();
84
+ }
85
+ // A press on a header (not focusable) keeps focus where it is, so it
86
+ // never reads as focus leaving the grid and a carry survives it.
87
+ function onMouseDown(event) {
88
+ if (!event.target.closest('[role="gridcell"]'))
89
+ event.preventDefault();
90
+ }
72
91
  function onKeyDown(event) {
73
92
  const position = cellPositionOf(event.target);
74
93
  if (!position)
@@ -106,7 +125,7 @@ export function ScheduleGrid(props) {
106
125
  pickUp(item.id, 'tap');
107
126
  }
108
127
  const activeTarget = carry?.mode === 'drag' ? hover : carry?.mode === 'keyboard' ? focus : null;
109
- return (_jsxs("div", { className: cx('sw-schedule-grid', carry?.mode === 'drag' && 'sw-schedule-grid-dragging'), children: [_jsxs("table", { "aria-describedby": instructionsId, "aria-label": label, className: "sw-schedule-grid-table", onKeyDown: onKeyDown, ref: tableRef, role: "grid", style: { '--sw-schedule-grid-columns': columns.length }, children: [_jsx("thead", { children: _jsxs("tr", { children: [rowsLabel !== undefined ? (_jsx("th", { className: "sw-schedule-grid-corner", scope: "col", children: rowsLabel })) : (_jsx("td", { "aria-hidden": "true", className: "sw-schedule-grid-corner" })), columns.map((column) => (_jsx("th", { className: "sw-schedule-grid-column", scope: "col", children: column.label }, column.id)))] }) }), _jsx("tbody", { children: rows.map((row, rowIndex) => (_jsxs("tr", { children: [_jsx("th", { className: "sw-schedule-grid-row", scope: "row", children: row.label }), columns.map((column, columnIndex) => {
128
+ return (_jsxs("div", { className: cx('sw-schedule-grid', carry?.mode === 'drag' && 'sw-schedule-grid-dragging'), children: [_jsxs("table", { "aria-describedby": instructionsId, "aria-label": label, className: "sw-schedule-grid-table", onBlur: onBlur, onKeyDown: onKeyDown, onMouseDown: onMouseDown, ref: tableRef, role: "grid", style: { '--sw-schedule-grid-columns': columns.length }, children: [_jsx("thead", { children: _jsxs("tr", { children: [rowsLabel !== undefined ? (_jsx("th", { className: "sw-schedule-grid-corner", scope: "col", children: rowsLabel })) : (_jsx("td", { "aria-hidden": "true", className: "sw-schedule-grid-corner" })), columns.map((column) => (_jsx("th", { className: "sw-schedule-grid-column", scope: "col", children: column.label }, column.id)))] }) }), _jsx("tbody", { children: rows.map((row, rowIndex) => (_jsxs("tr", { children: [_jsx("th", { className: "sw-schedule-grid-row", scope: "row", children: row.label }), columns.map((column, columnIndex) => {
110
129
  const position = { row: rowIndex, column: columnIndex };
111
130
  return (_jsx(ScheduleCell, { activeTarget: activeTarget?.row === rowIndex &&
112
131
  activeTarget.column === columnIndex, labels: words, model: model, movable: !disabled, onFocus: setFocused, onItemPointerDown: drag.onPointerDown, onPress: onPress, position: position, status: cellStatus(model, carry?.itemId ?? null, position, canDrop), tabStop: focus.row === rowIndex && focus.column === columnIndex }, column.id));
@@ -2,10 +2,10 @@ import { type HTMLAttributes } from 'react';
2
2
  import { type TableLayout, type TableStickyHeader, type TableVerticalAlign } from './table/table-layout.js';
3
3
  import { type TableMaxHeight } from './table/table-max-height.js';
4
4
  export { TableCell, type TableCellProps, type TableColumnWidth, type TableSort, } from './table/TableCell.js';
5
- export { TableRow, type TableRowProps, type TableRowVariant, } from './table/TableRow.js';
5
+ export { TableRow, type TableRowProps, type TableRowRule, type TableRowVariant, } from './table/TableRow.js';
6
6
  export { TableSelectCell, type TableSelectCellProps, } from './table/TableSelectCell.js';
7
7
  export type { TableCellTone } from '../css/css-table-ledger.js';
8
- export { tableCellTones, tableRowVariants } from '../css/css-table-ledger.js';
8
+ export { tableCellTones, tableRowRules, tableRowVariants, } from '../css/css-table-ledger.js';
9
9
  export type { TableLayout, TableMaxHeight, TableStickyHeader, TableVerticalAlign, };
10
10
  export type TableDensity = 'comfortable' | 'compact';
11
11
  export type TableProps = HTMLAttributes<HTMLTableElement> & {
@@ -7,7 +7,7 @@ import { assertTableMaxHeight, tableMaxHeightStyle, } from './table/table-max-he
7
7
  export { TableCell, } from './table/TableCell.js';
8
8
  export { TableRow, } from './table/TableRow.js';
9
9
  export { TableSelectCell, } from './table/TableSelectCell.js';
10
- export { tableCellTones, tableRowVariants } from '../css/css-table-ledger.js';
10
+ export { tableCellTones, tableRowRules, tableRowVariants, } from '../css/css-table-ledger.js';
11
11
  /**
12
12
  * A wide table scrolls inside its own `ScrollRegion`, named after the
13
13
  * table; a bounded one (`maxHeight`) scrolls its rows in it too.
@@ -6,9 +6,16 @@ export type DateToggleCalendarProps = {
6
6
  min: string;
7
7
  /** The last day that can be toggled (YYYY-MM-DD); the last month shown is its month. */
8
8
  max: string;
9
- /** The days toggled on (YYYY-MM-DD), in any order. */
9
+ /**
10
+ * The days toggled on (YYYY-MM-DD), in any order. Days outside
11
+ * `min`–`max` or disabled are not drawn, and `onChange` drops them.
12
+ */
10
13
  value: readonly string[];
11
- /** The days toggled on after a press, sorted and without repeats. */
14
+ /**
15
+ * The days toggled on after a press, sorted and without repeats; only
16
+ * days that can be toggled (inside `min`–`max` and not disabled), so a
17
+ * `value` entry outside them is dropped on the next press.
18
+ */
12
19
  onChange: (value: string[]) => void;
13
20
  /** Days between `min` and `max` that cannot be toggled, such as days with no games. */
14
21
  isDateDisabled?: (date: string) => boolean;
@@ -0,0 +1,19 @@
1
+ import { type FixtureScore } from './types.js';
2
+ /**
3
+ * The row's middle when it takes a score: the home box, an en dash and
4
+ * the away box, in a group named by `label`. Each box is a text input
5
+ * named by its own `label`, digits only (non-digits are dropped before
6
+ * `onChange`), its text selected on focus so a typed score replaces it.
7
+ */
8
+ export declare function FixtureScoreBoxes({ errorId, score, }: {
9
+ errorId: string;
10
+ score: FixtureScore;
11
+ }): import("react").JSX.Element;
12
+ /**
13
+ * The score's message under the row: a polite live region, present (and
14
+ * empty, taking no room) while there is no error, so a new one is said.
15
+ */
16
+ export declare function FixtureScoreError({ error, id, }: {
17
+ error: string | undefined;
18
+ id: string;
19
+ }): import("react").JSX.Element;
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cx } from '../../class-names.js';
4
+ import { scoreBoxMaxDigits, scoreDigits } from './score-digits.js';
5
+ /** Whether a box is marked: its own `invalid`, else any error. */
6
+ function boxInvalid(invalid, hasError) {
7
+ return invalid ?? hasError;
8
+ }
9
+ /**
10
+ * Enter records and Escape puts the boxes back. A key that ends an IME
11
+ * composition is the composition's, not the row's.
12
+ */
13
+ function scoreKeyDown(score) {
14
+ return (event) => {
15
+ if (event.nativeEvent.isComposing)
16
+ return;
17
+ if (event.key === 'Enter') {
18
+ event.preventDefault();
19
+ score.onSubmit();
20
+ }
21
+ else if (event.key === 'Escape' && score.onCancel) {
22
+ // Kept from an enclosing dialog, whose Escape would close it.
23
+ event.preventDefault();
24
+ score.onCancel();
25
+ }
26
+ };
27
+ }
28
+ function ScoreBox({ describedBy, disabled, inputRef, invalid, label, onDigits, onKeyDown, value, }) {
29
+ return (_jsx("input", { ref: inputRef, "aria-describedby": describedBy, "aria-invalid": invalid || undefined, "aria-label": label, autoComplete: "off", className: "sw-fixture-score-box", disabled: disabled, inputMode: "numeric", maxLength: scoreBoxMaxDigits, onChange: (event) => {
30
+ const digits = scoreDigits(event.currentTarget.value);
31
+ if (digits !== value)
32
+ onDigits(digits);
33
+ }, onFocus: (event) => event.currentTarget.select(), onKeyDown: onKeyDown, pattern: "[0-9]*", type: "text", value: value }));
34
+ }
35
+ /**
36
+ * The row's middle when it takes a score: the home box, an en dash and
37
+ * the away box, in a group named by `label`. Each box is a text input
38
+ * named by its own `label`, digits only (non-digits are dropped before
39
+ * `onChange`), its text selected on focus so a typed score replaces it.
40
+ */
41
+ export function FixtureScoreBoxes({ errorId, score, }) {
42
+ const hasError = Boolean(score.error);
43
+ const disabled = score.disabled ?? false;
44
+ const onKeyDown = scoreKeyDown(score);
45
+ const box = (side) => {
46
+ const { invalid, label, ref, value } = score[side];
47
+ return (_jsx(ScoreBox, { describedBy: hasError ? errorId : undefined, disabled: disabled, inputRef: ref, invalid: boxInvalid(invalid, hasError), label: label, onDigits: (digits) => score.onChange(side, digits), onKeyDown: onKeyDown, value: value }));
48
+ };
49
+ return (_jsxs("span", { "aria-label": score.label, className: cx('sw-fixture-center', 'sw-fixture-score'), role: "group", children: [box('home'), _jsx("span", { "aria-hidden": "true", className: "sw-fixture-score-dash", children: "\u2013" }), box('away')] }));
50
+ }
51
+ /**
52
+ * The score's message under the row: a polite live region, present (and
53
+ * empty, taking no room) while there is no error, so a new one is said.
54
+ */
55
+ export function FixtureScoreError({ error, id, }) {
56
+ return (_jsx("span", { "aria-live": "polite", className: "sw-fixture-score-error", id: id, children: error || null }));
57
+ }
@@ -0,0 +1,17 @@
1
+ import { type FixtureFlag, type FixtureSelect, type FixtureSide } from './types.js';
2
+ /**
3
+ * The issue marker at the row's start: a drawn mark (its tooltip the
4
+ * label) and the label in hidden words, read first in the row's name.
5
+ */
6
+ export declare function Flag({ flag }: {
7
+ flag: FixtureFlag;
8
+ }): import("react").JSX.Element;
9
+ /** The leading checkbox: a Checkbox with its label visually hidden. */
10
+ export declare function Select({ select }: {
11
+ select: FixtureSelect;
12
+ }): import("react").JSX.Element;
13
+ /** One side: its name, and its mark on the centre's side (hidden). */
14
+ export declare function Side({ side, place, }: {
15
+ side: FixtureSide;
16
+ place: 'home' | 'away';
17
+ }): import("react").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { hasSlot } from '../slot.js';
3
+ import { Checkbox } from '../Checkbox.js';
4
+ /**
5
+ * The issue marker at the row's start: a drawn mark (its tooltip the
6
+ * label) and the label in hidden words, read first in the row's name.
7
+ */
8
+ export function Flag({ flag }) {
9
+ return (_jsxs("span", { className: `sw-fixture-flag sw-fixture-flag-${flag.tone ?? 'warning'}`, children: [_jsx("span", { "aria-hidden": "true", className: "sw-fixture-flag-mark", title: flag.label }), _jsx("span", { className: "sw-sr-only", children: flag.label })] }));
10
+ }
11
+ /** The leading checkbox: a Checkbox with its label visually hidden. */
12
+ export function Select({ select }) {
13
+ return (_jsx("div", { className: "sw-fixture-select", children: _jsx(Checkbox, { checked: select.checked, disabled: select.disabled ?? false, label: select.label, labelVisuallyHidden: true, onCheckedChange: select.onCheckedChange }) }));
14
+ }
15
+ /** One side: its name, and its mark on the centre's side (hidden). */
16
+ export function Side({ side, place, }) {
17
+ const mark = hasSlot(side.mark) ? (_jsx("span", { "aria-hidden": "true", className: "sw-fixture-mark", children: side.mark })) : null;
18
+ return (_jsxs("span", { className: `sw-fixture-team sw-fixture-team-${place}`, children: [place === 'away' ? mark : null, _jsx("span", { className: "sw-fixture-name", children: side.name }), place === 'home' ? mark : null] }));
19
+ }
@@ -0,0 +1,7 @@
1
+ /** The most digits a score box holds. */
2
+ export declare const scoreBoxMaxDigits = 2;
3
+ /**
4
+ * What a score box keeps of typed or pasted text: its digits only, the
5
+ * first two ("1a2" → "12", "3" stays out as it is not an ASCII digit).
6
+ */
7
+ export declare function scoreDigits(typed: string): string;
@@ -0,0 +1,9 @@
1
+ /** The most digits a score box holds. */
2
+ export const scoreBoxMaxDigits = 2;
3
+ /**
4
+ * What a score box keeps of typed or pasted text: its digits only, the
5
+ * first two ("1a2" → "12", "3" stays out as it is not an ASCII digit).
6
+ */
7
+ export function scoreDigits(typed) {
8
+ return typed.replace(/[^0-9]/g, '').slice(0, scoreBoxMaxDigits);
9
+ }