@tristan2828/ui-foundation 4.40.0 → 4.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conventions/docs/cell-patterns.md +41 -10
- package/conventions/docs/design-language.md +102 -15
- package/dist/components/app/edit-in-place.d.ts +37 -12
- package/dist/components/app/editable-list.js +33 -16
- package/dist/components/app/editable-value.js +1 -1
- package/dist/components/app/fill-in-queue-index.d.ts +13 -0
- package/dist/components/app/fill-in-queue-index.js +33 -0
- package/dist/components/app/fill-in-queue.d.ts +62 -0
- package/dist/components/app/fill-in-queue.js +229 -0
- package/dist/components/app/spin-wheel-pick.d.ts +15 -0
- package/dist/components/app/spin-wheel-pick.js +21 -0
- package/dist/components/app/spin-wheel.d.ts +22 -0
- package/dist/components/app/spin-wheel.js +128 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/package.json +1 -1
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
- [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 564–647
|
|
27
27
|
- [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 649–684
|
|
28
28
|
- [17. Quick action on the view](#17-quick-action-on-the-view) · lines 686–759
|
|
29
|
-
- [18. Editable value](#18-editable-value) · lines 761–
|
|
30
|
-
- [A list of sub-items](#a-list-of-sub-items) · lines
|
|
31
|
-
- [In a table's rows](#in-a-tables-rows) · lines
|
|
32
|
-
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines
|
|
33
|
-
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines
|
|
34
|
-
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines
|
|
35
|
-
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines
|
|
36
|
-
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines
|
|
29
|
+
- [18. Editable value](#18-editable-value) · lines 761–1207
|
|
30
|
+
- [A list of sub-items](#a-list-of-sub-items) · lines 1023–1108
|
|
31
|
+
- [In a table's rows](#in-a-tables-rows) · lines 1110–1195
|
|
32
|
+
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1197–1207
|
|
33
|
+
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1209–1275
|
|
34
|
+
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1277–1341
|
|
35
|
+
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1343–1408
|
|
36
|
+
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1410–1438
|
|
37
37
|
|
|
38
38
|
<!-- /index -->
|
|
39
39
|
|
|
@@ -781,8 +781,11 @@ the form stay.
|
|
|
781
781
|
**At rest: says it's editable without shouting.** The value reads as it
|
|
782
782
|
always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
|
|
783
783
|
a real button ("Edit <label>"), so Tab reaches it and Enter opens the
|
|
784
|
-
field, and its focus ring tints the value too.
|
|
785
|
-
|
|
784
|
+
field, and its focus ring tints the value too. Muted text inside it (the
|
|
785
|
+
view's "Not set", a "No caption", a done item) turns `text-foreground`
|
|
786
|
+
on that tint, since muted on `bg-muted` is under 4.5:1; the package does
|
|
787
|
+
it, so an app just uses `text-muted-foreground` as anywhere else. On a
|
|
788
|
+
touch screen the pencil always shows. A click anywhere on the value opens it, except on a
|
|
786
789
|
link inside it (a reference to a record with a view, a link in the
|
|
787
790
|
notes), which still navigates.
|
|
788
791
|
|
|
@@ -1056,6 +1059,14 @@ Checklist is the reference (`checklist` in `widgetEdits`). It's built on
|
|
|
1056
1059
|
option is still inside the row, and Enter in the row's text box or
|
|
1057
1060
|
leaving the row saves the item, ids only on the wire. A new item starts
|
|
1058
1061
|
with no pick, which the list's schema refuses on that row.
|
|
1062
|
+
- **Items that come from elsewhere** (a record's files, added by upload):
|
|
1063
|
+
`add` in place of `newItem` and `addLabel`. It renders the app's own
|
|
1064
|
+
control (an "Add files" button over a hidden file input) and is given
|
|
1065
|
+
`add(items)`, which saves them at the end through `save`, as a move is
|
|
1066
|
+
("Saving…", a refusal under the list, the caret back on the control),
|
|
1067
|
+
plus `items` (to stop at a limit), `disabled` (a change is saving) and
|
|
1068
|
+
`onProblem(message)` (an upload refused, under the list). Each item
|
|
1069
|
+
then reads, edits, moves and removes like a checklist's.
|
|
1059
1070
|
|
|
1060
1071
|
```tsx
|
|
1061
1072
|
const checklist = editInPlace<ChecklistItem>({
|
|
@@ -1076,6 +1087,26 @@ The row's fields are one component shared with the form
|
|
|
1076
1087
|
(`WidgetChecklistItemFields` in `widget-fields.tsx`), each field named by
|
|
1077
1088
|
position ("Item 2 text") as the row buttons name the item.
|
|
1078
1089
|
|
|
1090
|
+
A list added by upload:
|
|
1091
|
+
|
|
1092
|
+
```tsx
|
|
1093
|
+
const files = editInPlace<Attachment>({
|
|
1094
|
+
kind: 'list',
|
|
1095
|
+
value: record.attachments,
|
|
1096
|
+
save: (change) => editField.mutateAsync((current) => ({ attachments: change(current.attachments) })),
|
|
1097
|
+
itemName: (index) => record.attachments[index]?.name ?? `file ${index + 1}`,
|
|
1098
|
+
add: ({ add, items, disabled, onProblem }) => (
|
|
1099
|
+
<UploadButton // the app's: a button over <input type="file" hidden>
|
|
1100
|
+
disabled={disabled || items.length >= MAX}
|
|
1101
|
+
onUploaded={(stored) => add(stored.map(toAttachment))}
|
|
1102
|
+
onRefused={(error) => onProblem(error.message)}
|
|
1103
|
+
/>
|
|
1104
|
+
),
|
|
1105
|
+
renderShown: (file) => <FileRow file={file} />, // its thumbnail, name and caption
|
|
1106
|
+
renderItem: (props) => <CaptionInput {...props} />, // what edits on a click: the caption
|
|
1107
|
+
})
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1079
1110
|
### In a table's rows
|
|
1080
1111
|
|
|
1081
1112
|
For a **small entity edited in its table** (a name and a few fields,
|
|
@@ -3,21 +3,23 @@
|
|
|
3
3
|
<!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
|
|
4
4
|
## Index
|
|
5
5
|
|
|
6
|
-
- [Semantic tones](#semantic-tones) · lines
|
|
7
|
-
- [The three badge styles](#the-three-badge-styles) · lines
|
|
8
|
-
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines
|
|
9
|
-
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines
|
|
10
|
-
- [Logos and brand marks](#logos-and-brand-marks) · lines
|
|
11
|
-
- [Categorical colour](#categorical-colour) · lines
|
|
12
|
-
- [Typography](#typography) · lines
|
|
13
|
-
- [Written text (notes)](#written-text-notes) · lines
|
|
14
|
-
- [Density](#density) · lines
|
|
15
|
-
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines
|
|
16
|
-
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines
|
|
17
|
-
- [Notices](#notices) · lines
|
|
18
|
-
- [A value over time](#a-value-over-time) · lines
|
|
19
|
-
- [A list as a board](#a-list-as-a-board) · lines
|
|
20
|
-
- [A list as cards](#a-list-as-cards) · lines
|
|
6
|
+
- [Semantic tones](#semantic-tones) · lines 32–60
|
|
7
|
+
- [The three badge styles](#the-three-badge-styles) · lines 62–79
|
|
8
|
+
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 81–110
|
|
9
|
+
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 112–125
|
|
10
|
+
- [Logos and brand marks](#logos-and-brand-marks) · lines 127–171
|
|
11
|
+
- [Categorical colour](#categorical-colour) · lines 173–243
|
|
12
|
+
- [Typography](#typography) · lines 245–327
|
|
13
|
+
- [Written text (notes)](#written-text-notes) · lines 276–327
|
|
14
|
+
- [Density](#density) · lines 329–386
|
|
15
|
+
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 388–466
|
|
16
|
+
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 468–526
|
|
17
|
+
- [Notices](#notices) · lines 528–556
|
|
18
|
+
- [A value over time](#a-value-over-time) · lines 558–591
|
|
19
|
+
- [A list as a board](#a-list-as-a-board) · lines 593–644
|
|
20
|
+
- [A list as cards](#a-list-as-cards) · lines 646–692
|
|
21
|
+
- [Picking at random](#picking-at-random) · lines 694–725
|
|
22
|
+
- [Filling in what's missing](#filling-in-whats-missing) · lines 727–777
|
|
21
23
|
|
|
22
24
|
<!-- /index -->
|
|
23
25
|
|
|
@@ -688,3 +690,88 @@ const { layout, setLayout } = useTableLayout({
|
|
|
688
690
|
dash. Never an action only one layout has.
|
|
689
691
|
- `totals` and `highlightRow` are the table's alone. Sorting is the
|
|
690
692
|
toolbar's in a card layout (there is no header to click).
|
|
693
|
+
|
|
694
|
+
## Picking at random
|
|
695
|
+
|
|
696
|
+
`SpinWheel` (4.42) picks one of a few candidates by chance, for "what do
|
|
697
|
+
I do next?" when every one would do: a wheel of slices, a pointer at the
|
|
698
|
+
top and a Spin button.
|
|
699
|
+
|
|
700
|
+
```tsx
|
|
701
|
+
const [picked, setPicked] = useState<SpinWheelItem | null>(null)
|
|
702
|
+
|
|
703
|
+
<SpinWheel
|
|
704
|
+
aria-label="Ready tasks"
|
|
705
|
+
items={tasks.map((task) => ({ id: task.id, label: task.title, weight: task.priority, emphasis: task.flagged }))}
|
|
706
|
+
result={picked?.id ?? null}
|
|
707
|
+
onResult={setPicked}
|
|
708
|
+
emptyMessage="Nothing ready to pick from"
|
|
709
|
+
/>
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
- **The candidates and their chances are the app's.** `weight` (> 0,
|
|
713
|
+
default 1) is both a slice's size and its chance, so a bigger slice
|
|
714
|
+
is visibly likelier. Keep it to what fits: a dozen or so; past that,
|
|
715
|
+
slices thin out and lose their labels (the full label is still each
|
|
716
|
+
slice's tooltip).
|
|
717
|
+
- **The result is the app's too:** `onResult` when the wheel stops, and
|
|
718
|
+
`result` is the slice drawn picked at rest (a solid fill). Show what
|
|
719
|
+
was picked next to the wheel, with what to do about it (a link to the
|
|
720
|
+
record, Start); the wheel only points.
|
|
721
|
+
- `emphasis` tints a slice (a flagged record). It doesn't change the
|
|
722
|
+
chance; give it a weight for that.
|
|
723
|
+
- It spins a few turns with an ease-out and says "Picked <label>" to a
|
|
724
|
+
screen reader; with reduced motion it doesn't turn, the result is at
|
|
725
|
+
once. Square, the width of its container up to 28rem.
|
|
726
|
+
|
|
727
|
+
## Filling in what's missing
|
|
728
|
+
|
|
729
|
+
`FillInQueue` (4.42) walks a list of records that are each missing a
|
|
730
|
+
value, one record at a time: a size for every project without one, a
|
|
731
|
+
category for each unsorted purchase. It is a screen of its own for
|
|
732
|
+
clearing a backlog of blanks, linked from the list ("12 without a
|
|
733
|
+
size"); the list stays where a record is edited one field at a time.
|
|
734
|
+
|
|
735
|
+
```tsx
|
|
736
|
+
<FillInQueue
|
|
737
|
+
items={missing}
|
|
738
|
+
getKey={(project) => project.id}
|
|
739
|
+
progress={{ done: total - missing.length, total }}
|
|
740
|
+
canSave={(project) => drafts[project.id]?.size != null}
|
|
741
|
+
onSave={(project) => saveSize.mutateAsync({ id: project.id, size: drafts[project.id].size })}
|
|
742
|
+
renderItem={(project) => (
|
|
743
|
+
<>
|
|
744
|
+
<RecordHeading project={project} />
|
|
745
|
+
<FillInChoice
|
|
746
|
+
label="Size"
|
|
747
|
+
choices={SIZES}
|
|
748
|
+
value={drafts[project.id]?.size}
|
|
749
|
+
onChange={(size) => setDraft(project.id, { size })}
|
|
750
|
+
suggestion={project.guess ? { value: project.guess, note: 'from similar projects' } : undefined}
|
|
751
|
+
shortcut
|
|
752
|
+
/>
|
|
753
|
+
</>
|
|
754
|
+
)}
|
|
755
|
+
loading={isLoading}
|
|
756
|
+
error={error}
|
|
757
|
+
onRetry={refetch}
|
|
758
|
+
empty="Every project has a size"
|
|
759
|
+
/>
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
- **The app owns the list, the draft and the save.** `items` is the
|
|
763
|
+
query for records still missing the value, so a saved one leaves it
|
|
764
|
+
and the next takes its place; one still missing something after a
|
|
765
|
+
save is moved past. `progress` is the app's count. The draft is kept
|
|
766
|
+
per record, so Skip and back finds it as it was.
|
|
767
|
+
- **A record is its heading, the few values to decide by, then its
|
|
768
|
+
fields:** a `FillInChoice` (a row of toggle buttons, one picked,
|
|
769
|
+
clicked again to clear) for a short choice, any input for the rest.
|
|
770
|
+
A guess is a `suggestion`: drawn dashed in the info tone and said
|
|
771
|
+
under the row, never picked for the person.
|
|
772
|
+
- **Quick from the keyboard:** Enter is Save and next (not in a
|
|
773
|
+
textarea), and the one row marked `shortcut` takes the number keys.
|
|
774
|
+
After a move the focus is on the new record.
|
|
775
|
+
- A refused save keeps the record and says why under the buttons, not
|
|
776
|
+
in a toast. It owns its states: skeletons, `ErrorState`, and the
|
|
777
|
+
empty queue, which is the good news ("Every project has a size").
|
|
@@ -123,14 +123,31 @@ export type EditListItemProps<TItem> = EditControlProps<TItem> & {
|
|
|
123
123
|
/** The item being added at the end, not one already in the list. */
|
|
124
124
|
isNew: boolean;
|
|
125
125
|
};
|
|
126
|
+
/** What a list's own `add` is given: how to put new items at the end, saved as the list's other changes are. */
|
|
127
|
+
export type EditListAddProps<TItem> = {
|
|
128
|
+
/**
|
|
129
|
+
* Saves these items at the end of the list, through the list's `save`
|
|
130
|
+
* (built on its latest list, after whatever is open and whatever is
|
|
131
|
+
* still saving). "Saving…" and a refusal show under the list, as for a
|
|
132
|
+
* move. Resolves once it's done: `true` saved, `false` refused.
|
|
133
|
+
*/
|
|
134
|
+
add: (items: TItem[]) => Promise<boolean>;
|
|
135
|
+
/** The list as saved: to stop adding at a limit (`items.length >= max`). */
|
|
136
|
+
items: readonly TItem[];
|
|
137
|
+
/** A change is saving: the list's buttons wait, and so should this one. */
|
|
138
|
+
disabled: boolean;
|
|
139
|
+
/** Why the add can't happen (an upload refused), under the list where a refused save shows; `null` clears it. */
|
|
140
|
+
onProblem: (message: string | null) => void;
|
|
141
|
+
};
|
|
126
142
|
/**
|
|
127
|
-
* A list of sub-items edited in place (a checklist, a list of links
|
|
128
|
-
* a `content` section of EntityView. Make one with
|
|
129
|
-
* 'list', ... })`. Every item reads as `renderShown`
|
|
130
|
-
* as its done box, still works there); clicking it
|
|
131
|
-
* into `renderItem`, the form's own row. "Add" at
|
|
132
|
-
*
|
|
133
|
-
*
|
|
143
|
+
* A list of sub-items edited in place (a checklist, a list of links, a
|
|
144
|
+
* record's files), on a `content` section of EntityView. Make one with
|
|
145
|
+
* `editInPlace({ kind: 'list', ... })`. Every item reads as `renderShown`
|
|
146
|
+
* (a quick action, such as its done box, still works there); clicking it
|
|
147
|
+
* or its pencil turns it into `renderItem`, the form's own row. "Add" at
|
|
148
|
+
* the end opens a new item (`newItem`), or the app's own `add` puts items
|
|
149
|
+
* there (an upload); move up, move down and remove are ListEditor's
|
|
150
|
+
* buttons. Each change is one save of the whole list.
|
|
134
151
|
*/
|
|
135
152
|
export type EditInPlaceListOptions<TItem> = {
|
|
136
153
|
kind: 'list';
|
|
@@ -145,12 +162,19 @@ export type EditInPlaceListOptions<TItem> = {
|
|
|
145
162
|
renderItem: (props: EditListItemProps<TItem>) => ReactNode;
|
|
146
163
|
/** How the item reads when it isn't being edited. Its own controls (a done box) keep working. */
|
|
147
164
|
renderShown: (item: TItem, index: number) => ReactNode;
|
|
148
|
-
/** A blank item, what "Add" starts from. */
|
|
149
|
-
newItem
|
|
165
|
+
/** A blank item, what "Add" starts from. Not needed with `add`; one or the other is. */
|
|
166
|
+
newItem?: () => TItem;
|
|
167
|
+
/**
|
|
168
|
+
* In place of "Add" and its blank item: what adds items, such as a file
|
|
169
|
+
* upload or a picker, e.g. a button that uploads, then calls
|
|
170
|
+
* `props.add(stored.map(toItem))`. The items save at the end of the list
|
|
171
|
+
* through `save`, so they read, move and save like any other.
|
|
172
|
+
*/
|
|
173
|
+
add?: (props: EditListAddProps<TItem>) => ReactNode;
|
|
150
174
|
/** How the buttons name an item, as on the form: (i) => `item ${i + 1}` gives "Move item 2 up". */
|
|
151
175
|
itemName: (index: number) => string;
|
|
152
|
-
/** The Add button's text, e.g. "Add item". */
|
|
153
|
-
addLabel
|
|
176
|
+
/** The Add button's text, e.g. "Add item". Not needed with `add`, whose control has its own. */
|
|
177
|
+
addLabel?: string;
|
|
154
178
|
/**
|
|
155
179
|
* The form's schema for the whole list (`widgetFormSchema.shape.checklist`).
|
|
156
180
|
* Each change is checked as the list it would make; a message about an
|
|
@@ -169,7 +193,8 @@ export type EditInPlaceListOptions<TItem> = {
|
|
|
169
193
|
save: (change: (current: TItem[]) => TItem[]) => Promise<unknown>;
|
|
170
194
|
};
|
|
171
195
|
/** A list marked editable in place. Make one with `editInPlace({ kind: 'list', ... })`. */
|
|
172
|
-
export type EditInPlaceList = Omit<EditInPlaceListOptions<unknown>, 'renderItem' | 'renderShown' | 'schema' | 'save'> & {
|
|
196
|
+
export type EditInPlaceList = Omit<EditInPlaceListOptions<unknown>, 'renderItem' | 'renderShown' | 'schema' | 'save' | 'add'> & {
|
|
197
|
+
add?: (props: EditListAddProps<unknown>) => ReactNode;
|
|
173
198
|
renderItem: (props: EditListItemProps<unknown>) => ReactNode;
|
|
174
199
|
renderShown: (item: unknown, index: number) => ReactNode;
|
|
175
200
|
schema?: {
|
|
@@ -9,7 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
// form's row (`renderItem`), an EditableValue like any field: leaving or
|
|
10
10
|
// Enter saves, Esc gives up, one field open at a time.
|
|
11
11
|
// - "Add" at the end opens a new item the same way. Enter saves it and
|
|
12
|
-
// opens the next, so a run of items goes in from the keyboard.
|
|
12
|
+
// opens the next, so a run of items goes in from the keyboard. Or the
|
|
13
|
+
// app's own `add` (an upload) puts items at the end, saved as a move is.
|
|
13
14
|
// - Move and remove save at once. Whatever is open is saved first; while
|
|
14
15
|
// the change saves, the buttons wait, "Saving…" shows under the list,
|
|
15
16
|
// and a refusal leaves the list as it was with the reason under it.
|
|
@@ -56,16 +57,22 @@ function checkItem(edit, items, index, draft) {
|
|
|
56
57
|
const issue = issues.find(({ path }) => path?.[0] === index) ?? issues.find(({ path }) => !path?.length) ?? issues[0];
|
|
57
58
|
return { success: false, error: { issues: issue ? [issue] : [] } };
|
|
58
59
|
}
|
|
60
|
+
// The app's own add, as a component of its own: it renders the app's
|
|
61
|
+
// control with what it's given, and holds nothing.
|
|
62
|
+
function ListAdd({ render, ...props }) {
|
|
63
|
+
return _jsx("div", { "data-list-add-slot": "", children: render(props) });
|
|
64
|
+
}
|
|
59
65
|
export function EditableList({ label, edit, emptyLabel }) {
|
|
60
66
|
const store = useContext(EditInPlaceContext);
|
|
61
67
|
if (!store)
|
|
62
68
|
throw new Error('A list edited in place works inside an <EntityView>, which runs editing in place.');
|
|
63
69
|
const items = edit.value;
|
|
64
70
|
const statusId = useId();
|
|
65
|
-
const [status, setStatus] = useState({ saving: false, error: null });
|
|
71
|
+
const [status, setStatus] = useState({ saving: false, inFlight: 0, error: null });
|
|
66
72
|
// The buttons are disabled while a change saves, which drops the caret.
|
|
67
73
|
// Once the list shows the outcome it goes to the first of these buttons
|
|
68
|
-
// (by name) that's there and enabled, else to Add
|
|
74
|
+
// (by name) that's there and enabled, else to Add (or the app's add's
|
|
75
|
+
// first button).
|
|
69
76
|
const root = useRef(null);
|
|
70
77
|
const focusNext = useRef(null);
|
|
71
78
|
useEffect(() => {
|
|
@@ -75,26 +82,30 @@ export function EditableList({ label, edit, emptyLabel }) {
|
|
|
75
82
|
focusNext.current = null;
|
|
76
83
|
const buttons = [...(root.current?.querySelectorAll('button:not(:disabled)') ?? [])];
|
|
77
84
|
const target = names.map((name) => buttons.find((button) => button.getAttribute('aria-label') === name)).find(Boolean);
|
|
78
|
-
(target ?? root.current?.querySelector('[data-list-add]:not(:disabled)'))?.focus();
|
|
85
|
+
(target ?? root.current?.querySelector('[data-list-add]:not(:disabled), [data-list-add-slot] button:not(:disabled)'))?.focus();
|
|
79
86
|
}, [items, status.saving]);
|
|
80
|
-
// Move and
|
|
81
|
-
// and nothing else happens), then the change.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
// Move, remove and the app's own add: save the open field first (if it
|
|
88
|
+
// can't save, it stays and nothing else happens), then the change. An
|
|
89
|
+
// add that lands while a change saves (an upload finishing) still goes:
|
|
90
|
+
// `save` builds on the latest list, and "Saving…" stays until both are done.
|
|
91
|
+
const act = async (change, focusAfter, focusIfRefused, waits = true) => {
|
|
92
|
+
if (waits && status.saving)
|
|
93
|
+
return false;
|
|
85
94
|
const open = store.open;
|
|
86
95
|
if (open !== null && !(await store.commit(open)))
|
|
87
|
-
return;
|
|
88
|
-
setStatus({ saving: true, error: null });
|
|
96
|
+
return false;
|
|
97
|
+
setStatus((now) => ({ saving: true, inFlight: now.inFlight + 1, error: null }));
|
|
98
|
+
let error = null;
|
|
89
99
|
try {
|
|
90
100
|
await edit.save(change);
|
|
91
101
|
focusNext.current = focusAfter;
|
|
92
|
-
setStatus({ saving: false, error: null });
|
|
93
102
|
}
|
|
94
|
-
catch (
|
|
103
|
+
catch (refused) {
|
|
95
104
|
focusNext.current = focusIfRefused;
|
|
96
|
-
|
|
105
|
+
error = messageOf(refused);
|
|
97
106
|
}
|
|
107
|
+
setStatus((now) => ({ saving: now.inFlight > 1, inFlight: now.inFlight - 1, error: error ?? now.error }));
|
|
108
|
+
return error === null;
|
|
98
109
|
};
|
|
99
110
|
const move = (from, to) => {
|
|
100
111
|
const direction = to < from ? 'up' : 'down';
|
|
@@ -124,13 +135,19 @@ export function EditableList({ label, edit, emptyLabel }) {
|
|
|
124
135
|
return current.map((other, i) => (i === at ? item : other));
|
|
125
136
|
}),
|
|
126
137
|
});
|
|
138
|
+
// The app's own add: its items saved at the end, the caret back on it.
|
|
139
|
+
const addItems = (added) => added.length === 0 ? Promise.resolve(true) : act((current) => [...current, ...added], [], [], false);
|
|
140
|
+
const onProblem = (message) => setStatus((now) => ({ ...now, error: message }));
|
|
127
141
|
// The item being added, at the end.
|
|
142
|
+
const newItem = edit.newItem;
|
|
143
|
+
if (!newItem && !edit.add)
|
|
144
|
+
throw new Error('A list edited in place needs `newItem` (a blank item to add) or `add` (its own control).');
|
|
128
145
|
const addEdit = {
|
|
129
146
|
kind: 'text',
|
|
130
|
-
value:
|
|
147
|
+
value: newItem?.(),
|
|
131
148
|
control: (props) => _jsx(ItemRow, { children: edit.renderItem({ ...props, index: items.length, isNew: true }) }),
|
|
132
149
|
schema: { safeParse: (draft) => checkItem(edit, items, items.length, draft) },
|
|
133
150
|
save: (item) => edit.save((current) => [...current, item]),
|
|
134
151
|
};
|
|
135
|
-
return (_jsxs("div", { ref: root, className: "flex flex-col gap-2", "data-slot": "editable-list", children: [_jsx(ListEditor, { "aria-label": label, items: items.map((_, index) => ({ id: String(index) })), itemName: edit.itemName, addLabel: edit.addLabel, emptyText: emptyLabel, disabled: status.saving, onRemove: remove, onMove: move, renderItem: (index) => (_jsx(EditableField, { label: edit.itemName(index), edit: itemEdit(index), children: edit.renderShown(items[index], index) })), add: _jsx(EditableField, { label: edit.itemName(items.length), edit: addEdit, reopenAfterKeyboardSave: true, children: null, trigger: (start) => (_jsx("div", { children: _jsxs(Button, { "data-list-add": "", type: "button", variant: "outline", size: "sm", disabled: status.saving, onClick: start, children: [_jsx(PlusIcon, {}), edit.addLabel] }) })) }) }), _jsx("span", { id: statusId, className: "type-body", children: status.saving ? (_jsxs("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (status.error && _jsx(FieldError, { className: "type-caption", children: status.error })) })] }));
|
|
152
|
+
return (_jsxs("div", { ref: root, className: "flex flex-col gap-2", "data-slot": "editable-list", children: [_jsx(ListEditor, { "aria-label": label, items: items.map((_, index) => ({ id: String(index) })), itemName: edit.itemName, addLabel: edit.addLabel ?? '', emptyText: emptyLabel, disabled: status.saving, onRemove: remove, onMove: move, renderItem: (index) => (_jsx(EditableField, { label: edit.itemName(index), edit: itemEdit(index), children: edit.renderShown(items[index], index) })), add: edit.add ? (_jsx(ListAdd, { render: edit.add, add: addItems, items: items, disabled: status.saving, onProblem: onProblem })) : (_jsx(EditableField, { label: edit.itemName(items.length), edit: addEdit, reopenAfterKeyboardSave: true, children: null, trigger: (start) => (_jsx("div", { children: _jsxs(Button, { "data-list-add": "", type: "button", variant: "outline", size: "sm", disabled: status.saving, onClick: start, children: [_jsx(PlusIcon, {}), edit.addLabel] }) })) })) }), _jsx("span", { id: statusId, className: "type-body", children: status.saving ? (_jsxs("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (status.error && _jsx(FieldError, { className: "type-caption", children: status.error })) })] }));
|
|
136
153
|
}
|
|
@@ -206,7 +206,7 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
206
206
|
if (state.status === 'closed' && !always) {
|
|
207
207
|
if (trigger)
|
|
208
208
|
return (_jsx("span", { ref: triggerBox, className: "contents", children: trigger(start) }));
|
|
209
|
-
return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
|
|
209
|
+
return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1 group-hover/editable:[&_.text-muted-foreground]:text-foreground group-has-[[data-edit]:focus-visible]/editable:[&_.text-muted-foreground]:text-foreground", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
|
|
210
210
|
}
|
|
211
211
|
// Always editable, before the store has it (the first render): the saved value.
|
|
212
212
|
const open = state.status === 'closed' ? { status: 'open', draft: edit.value, error: null, autosave: undefined, revision: 0 } : state;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type FillInKey = string | number;
|
|
2
|
+
/**
|
|
3
|
+
* The index to show: the held key's, while it's in the list; else the
|
|
4
|
+
* place it was at (`lastIndex`), kept inside the list, which now holds the
|
|
5
|
+
* record after it. -1 for an empty list.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveFillInIndex(keys: readonly FillInKey[], key: FillInKey | null, lastIndex: number): number;
|
|
8
|
+
/**
|
|
9
|
+
* The key after `key`, wrapping to the first; `key` itself when it is the
|
|
10
|
+
* only one. When `key` isn't in the list, the one now at `lastIndex`.
|
|
11
|
+
* Null for an empty list.
|
|
12
|
+
*/
|
|
13
|
+
export declare function nextFillInKey(keys: readonly FillInKey[], key: FillInKey | null, lastIndex: number): FillInKey | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Which record FillInQueue shows, as its `items` change under it. The
|
|
2
|
+
// record is held by its key, not its place: saving one usually takes it out
|
|
3
|
+
// of `items` (it isn't missing anything now), and the list may arrive after
|
|
4
|
+
// the save resolves or before. Holding the key of the record to show next
|
|
5
|
+
// makes both orders land on the same one.
|
|
6
|
+
/**
|
|
7
|
+
* The index to show: the held key's, while it's in the list; else the
|
|
8
|
+
* place it was at (`lastIndex`), kept inside the list, which now holds the
|
|
9
|
+
* record after it. -1 for an empty list.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveFillInIndex(keys, key, lastIndex) {
|
|
12
|
+
if (keys.length === 0)
|
|
13
|
+
return -1;
|
|
14
|
+
if (key !== null) {
|
|
15
|
+
const found = keys.indexOf(key);
|
|
16
|
+
if (found !== -1)
|
|
17
|
+
return found;
|
|
18
|
+
}
|
|
19
|
+
return Math.min(Math.max(lastIndex, 0), keys.length - 1);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The key after `key`, wrapping to the first; `key` itself when it is the
|
|
23
|
+
* only one. When `key` isn't in the list, the one now at `lastIndex`.
|
|
24
|
+
* Null for an empty list.
|
|
25
|
+
*/
|
|
26
|
+
export function nextFillInKey(keys, key, lastIndex) {
|
|
27
|
+
if (keys.length === 0)
|
|
28
|
+
return null;
|
|
29
|
+
const at = key === null ? -1 : keys.indexOf(key);
|
|
30
|
+
if (at === -1)
|
|
31
|
+
return keys[resolveFillInIndex(keys, null, lastIndex)];
|
|
32
|
+
return keys[(at + 1) % keys.length];
|
|
33
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { AppError } from '../../api/contracts.js';
|
|
3
|
+
export interface FillInQueueProgress {
|
|
4
|
+
/** Records filled so far. */
|
|
5
|
+
done: number;
|
|
6
|
+
/** Records in all, filled or not. */
|
|
7
|
+
total: number;
|
|
8
|
+
/** The word after the count: "12 of 160 <label>". Default "filled". */
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface FillInQueueProps<T> {
|
|
12
|
+
/** The records still missing something, in order. */
|
|
13
|
+
items: readonly T[];
|
|
14
|
+
getKey: (item: T) => string | number;
|
|
15
|
+
/** One record: its heading and values, then its fields. */
|
|
16
|
+
renderItem: (item: T) => ReactNode;
|
|
17
|
+
/** Saves the app's draft for the record; the queue moves on when it resolves. A rejection keeps the record and shows its message. */
|
|
18
|
+
onSave: (item: T) => Promise<void> | void;
|
|
19
|
+
/** False disables Save and next (nothing picked yet); a function is asked about the record on screen. Default true. */
|
|
20
|
+
canSave?: boolean | ((item: T) => boolean);
|
|
21
|
+
/** Told when Skip moves past a record without saving. */
|
|
22
|
+
onSkip?: (item: T) => void;
|
|
23
|
+
/** A bar and "12 of 160 filled" at the top. The app counts. */
|
|
24
|
+
progress?: FillInQueueProgress;
|
|
25
|
+
/** Default "Save and next". */
|
|
26
|
+
saveLabel?: string;
|
|
27
|
+
/** Default "Skip". */
|
|
28
|
+
skipLabel?: string;
|
|
29
|
+
loading?: boolean;
|
|
30
|
+
/** An AppError (ErrorState, with `onRetry`), or the app's own message. */
|
|
31
|
+
error?: AppError | ReactNode;
|
|
32
|
+
onRetry?: () => void;
|
|
33
|
+
/** The empty queue's title. Default "Nothing left to fill in". */
|
|
34
|
+
empty?: ReactNode;
|
|
35
|
+
/** Shows the record at this index; the queue still moves on its own after a save or a skip and reports it. */
|
|
36
|
+
index?: number;
|
|
37
|
+
/** Told whenever another record is on screen, or the one on screen moves to another index. */
|
|
38
|
+
onIndexChange?: (index: number, item: T) => void;
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function FillInQueue<T>({ items, getKey, renderItem, onSave, canSave, onSkip, progress, saveLabel, skipLabel, loading, error, onRetry, empty, index: indexProp, onIndexChange, className, }: FillInQueueProps<T>): import("react").JSX.Element;
|
|
42
|
+
export interface FillInChoiceProps<V extends string | number> {
|
|
43
|
+
/** Names the row of choices. */
|
|
44
|
+
label: string;
|
|
45
|
+
choices: readonly {
|
|
46
|
+
value: V;
|
|
47
|
+
label: ReactNode;
|
|
48
|
+
}[];
|
|
49
|
+
value: V | null | undefined;
|
|
50
|
+
/** The choice picked, or null when the picked one is clicked again. */
|
|
51
|
+
onChange: (value: V | null) => void;
|
|
52
|
+
/** A guess drawn dashed in the info tone, with "Suggested: <label> (<note>)" under the row. */
|
|
53
|
+
suggestion?: {
|
|
54
|
+
value: V;
|
|
55
|
+
note?: ReactNode;
|
|
56
|
+
};
|
|
57
|
+
/** The number keys pick in this row while the focus is in the queue (not in a text field). One row per queue. */
|
|
58
|
+
shortcut?: boolean;
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
/** One field of a FillInQueue record as a row of toggle buttons: one picked, or none. */
|
|
62
|
+
export declare function FillInChoice<V extends string | number>({ label, choices, value, onChange, suggestion, shortcut, className, }: FillInChoiceProps<V>): import("react").JSX.Element;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Fill in what a list of records is missing, one record at a time: a size
|
|
3
|
+
// for every project without one, a category for each unsorted purchase.
|
|
4
|
+
// A screen for clearing a backlog of blanks quickly, beside the list
|
|
5
|
+
// (where the same fields stay editable one by one), never instead of it.
|
|
6
|
+
//
|
|
7
|
+
// - Records: `items` are the ones still missing something, in order, and
|
|
8
|
+
// `renderItem` draws one: its heading and a few values to decide by,
|
|
9
|
+
// then its fields (FillInChoice, or any input). The draft is the app's:
|
|
10
|
+
// it keeps what's picked for each record, and `canSave` says whether
|
|
11
|
+
// there's enough to save.
|
|
12
|
+
// - Moving: "Save and next" calls `onSave(item)` (the app saves its draft)
|
|
13
|
+
// and, when it resolves, shows the next record. A saved record usually
|
|
14
|
+
// leaves `items`, so the one after it takes its place; if it stays (still
|
|
15
|
+
// missing something), the queue moves past it. Skip moves on without
|
|
16
|
+
// saving, wrapping from the last record to the first. After either, the
|
|
17
|
+
// focus is on the new record, for a screen reader and the keys below.
|
|
18
|
+
// - Keys, while the focus is inside the queue: Enter (not in a textarea,
|
|
19
|
+
// not on a button) is Save and next; with one FillInChoice marked
|
|
20
|
+
// `shortcut`, the number keys pick its choices (not in a text field).
|
|
21
|
+
// - Saving: the button shows it's pending; a refusal keeps the record and
|
|
22
|
+
// says why under the buttons, not in a toast.
|
|
23
|
+
// - Progress: the app counts (`progress`): a bar and "12 of 160 filled".
|
|
24
|
+
// - States: `loading` (skeletons), `error` (ErrorState, with `onRetry`),
|
|
25
|
+
// and an empty queue (`empty`, default "Nothing left to fill in").
|
|
26
|
+
//
|
|
27
|
+
// The record is a centred column, 40rem at most; the buttons sit under it,
|
|
28
|
+
// Skip at the start and Save and next at the end.
|
|
29
|
+
import { createContext, isValidElement, useCallback, useContext, useEffect, useId, useRef, useState, } from 'react';
|
|
30
|
+
import { cn } from 'cn';
|
|
31
|
+
import { ErrorState } from './error-state.js';
|
|
32
|
+
import { nextFillInKey, resolveFillInIndex } from './fill-in-queue-index.js';
|
|
33
|
+
import { Button } from '../ui/button.js';
|
|
34
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
|
|
35
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
36
|
+
import { Spinner } from '../ui/spinner.js';
|
|
37
|
+
import { Toggle } from '../ui/toggle.js';
|
|
38
|
+
const ShortcutContext = createContext(null);
|
|
39
|
+
const isAppError = (value) => typeof value === 'object' &&
|
|
40
|
+
value !== null &&
|
|
41
|
+
!isValidElement(value) &&
|
|
42
|
+
typeof value.kind === 'string' &&
|
|
43
|
+
typeof value.message === 'string';
|
|
44
|
+
const messageOf = (reason) => {
|
|
45
|
+
if (typeof reason === 'string' && reason !== '')
|
|
46
|
+
return reason;
|
|
47
|
+
if (typeof reason === 'object' && reason !== null && typeof reason.message === 'string') {
|
|
48
|
+
const message = reason.message;
|
|
49
|
+
if (message !== '')
|
|
50
|
+
return message;
|
|
51
|
+
}
|
|
52
|
+
return "Couldn't save this record.";
|
|
53
|
+
};
|
|
54
|
+
const TEXT_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password', 'number', 'date', 'time', 'datetime-local', 'month', 'week']);
|
|
55
|
+
// Where a key types into a field, so it's the field's, not the queue's.
|
|
56
|
+
function isTextField(element) {
|
|
57
|
+
if (element.isContentEditable)
|
|
58
|
+
return true;
|
|
59
|
+
if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)
|
|
60
|
+
return true;
|
|
61
|
+
if (element instanceof HTMLInputElement)
|
|
62
|
+
return TEXT_INPUT_TYPES.has(element.type);
|
|
63
|
+
return element.getAttribute('role') === 'combobox' || element.getAttribute('role') === 'textbox';
|
|
64
|
+
}
|
|
65
|
+
// Where Enter already does something of its own.
|
|
66
|
+
function ownsEnter(element) {
|
|
67
|
+
if (element.isContentEditable || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)
|
|
68
|
+
return true;
|
|
69
|
+
return (element.closest('button, a[href], summary, [role=button], [role=link], [role=combobox], [role=listbox], [role=option], [role=menu], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=switch], [role=checkbox], [role=radio], [role=tab]') !== null);
|
|
70
|
+
}
|
|
71
|
+
export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true, onSkip, progress, saveLabel = 'Save and next', skipLabel = 'Skip', loading = false, error, onRetry, empty = 'Nothing left to fill in', index: indexProp, onIndexChange, className, }) {
|
|
72
|
+
const keys = items.map(getKey);
|
|
73
|
+
const [held, setHeld] = useState(() => ({
|
|
74
|
+
key: indexProp !== undefined ? (keys[indexProp] ?? null) : null,
|
|
75
|
+
index: indexProp ?? 0,
|
|
76
|
+
}));
|
|
77
|
+
const index = resolveFillInIndex(keys, held.key, held.index);
|
|
78
|
+
const current = index === -1 ? undefined : items[index];
|
|
79
|
+
const currentKey = index === -1 ? null : keys[index];
|
|
80
|
+
const savable = current !== undefined && (typeof canSave === 'function' ? canSave(current) : canSave);
|
|
81
|
+
const [saving, setSaving] = useState(false);
|
|
82
|
+
const [saveError, setSaveError] = useState(null);
|
|
83
|
+
const [moves, setMoves] = useState(0);
|
|
84
|
+
const rootRef = useRef(null);
|
|
85
|
+
const recordRef = useRef(null);
|
|
86
|
+
const latest = useRef({ keys, index, items });
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
latest.current = { keys, index, items };
|
|
89
|
+
});
|
|
90
|
+
// Re-pin the hold to the record on screen once the one held has left
|
|
91
|
+
// (adjusting state while rendering, so nothing draws the old one).
|
|
92
|
+
if (index !== -1 && (held.key !== currentKey || held.index !== index))
|
|
93
|
+
setHeld({ key: currentKey, index });
|
|
94
|
+
// The app moving the queue to an index.
|
|
95
|
+
const appliedIndex = useRef(indexProp);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (indexProp === undefined || indexProp === appliedIndex.current)
|
|
98
|
+
return;
|
|
99
|
+
appliedIndex.current = indexProp;
|
|
100
|
+
const { keys: now } = latest.current;
|
|
101
|
+
if (indexProp >= 0 && indexProp < now.length)
|
|
102
|
+
setHeld({ key: now[indexProp], index: indexProp });
|
|
103
|
+
}, [indexProp]);
|
|
104
|
+
const reported = useRef({ index, key: currentKey });
|
|
105
|
+
const onIndexChangeRef = useRef(onIndexChange);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
onIndexChangeRef.current = onIndexChange;
|
|
108
|
+
});
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (index === reported.current.index && currentKey === reported.current.key)
|
|
111
|
+
return;
|
|
112
|
+
reported.current = { index, key: currentKey };
|
|
113
|
+
appliedIndex.current = index;
|
|
114
|
+
const item = latest.current.items[index];
|
|
115
|
+
if (index !== -1 && item !== undefined)
|
|
116
|
+
onIndexChangeRef.current?.(index, item);
|
|
117
|
+
}, [index, currentKey]);
|
|
118
|
+
// After a move, the focus goes to the record now on screen (to the queue
|
|
119
|
+
// when none is left).
|
|
120
|
+
const focusPending = useRef(false);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!focusPending.current)
|
|
123
|
+
return;
|
|
124
|
+
focusPending.current = false;
|
|
125
|
+
(recordRef.current ?? rootRef.current)?.focus();
|
|
126
|
+
}, [moves]);
|
|
127
|
+
const moveOn = useCallback((from) => {
|
|
128
|
+
const { keys: now, index: at } = latest.current;
|
|
129
|
+
const next = nextFillInKey(now, from, at);
|
|
130
|
+
setHeld({ key: next, index: next === null ? at : now.indexOf(next) });
|
|
131
|
+
setSaveError(null);
|
|
132
|
+
focusPending.current = true;
|
|
133
|
+
setMoves((count) => count + 1);
|
|
134
|
+
}, []);
|
|
135
|
+
const save = async () => {
|
|
136
|
+
if (saving || !savable || current === undefined || currentKey === null)
|
|
137
|
+
return;
|
|
138
|
+
setSaving(true);
|
|
139
|
+
setSaveError(null);
|
|
140
|
+
try {
|
|
141
|
+
await onSave(current);
|
|
142
|
+
}
|
|
143
|
+
catch (reason) {
|
|
144
|
+
setSaving(false);
|
|
145
|
+
setSaveError(messageOf(reason));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
setSaving(false);
|
|
149
|
+
moveOn(currentKey);
|
|
150
|
+
};
|
|
151
|
+
const skip = () => {
|
|
152
|
+
if (saving || current === undefined || currentKey === null)
|
|
153
|
+
return;
|
|
154
|
+
onSkip?.(current);
|
|
155
|
+
moveOn(currentKey);
|
|
156
|
+
};
|
|
157
|
+
const shortcuts = useRef(new Set());
|
|
158
|
+
const register = useCallback((handler) => {
|
|
159
|
+
shortcuts.current.add(handler);
|
|
160
|
+
return () => {
|
|
161
|
+
shortcuts.current.delete(handler);
|
|
162
|
+
};
|
|
163
|
+
}, []);
|
|
164
|
+
const onKeyDown = (event) => {
|
|
165
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
|
|
166
|
+
return;
|
|
167
|
+
const target = event.target;
|
|
168
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
169
|
+
if (ownsEnter(target))
|
|
170
|
+
return;
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
void save();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (/^[1-9]$/.test(event.key) && !event.shiftKey && !isTextField(target)) {
|
|
176
|
+
for (const handler of shortcuts.current) {
|
|
177
|
+
if (handler(Number(event.key))) {
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const progressBar = progress && _jsx(ProgressLine, { progress: progress });
|
|
185
|
+
if (error !== undefined && error !== null && error !== false) {
|
|
186
|
+
return (_jsx("div", { "data-slot": "fill-in-queue", "data-state": "error", className: className, children: isAppError(error) ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : (_jsxs(Empty, { "data-state": "error", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: "Something went wrong" }), _jsx(EmptyDescription, { children: error })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, children: "Try again" }))] })) }));
|
|
187
|
+
}
|
|
188
|
+
if (loading) {
|
|
189
|
+
return (_jsxs("div", { "data-slot": "fill-in-queue", "data-state": "loading", "aria-busy": "true", className: cn('flex flex-col gap-6', className), children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-2 w-full" }), _jsx(Skeleton, { className: "h-4 w-40" })] }), _jsxs("div", { className: "mx-auto flex w-full max-w-[40rem] flex-col gap-4", children: [_jsx(Skeleton, { className: "h-6 w-2/3" }), _jsx(Skeleton, { className: "h-4 w-1/2" }), _jsx(Skeleton, { className: "h-9 w-full" }), _jsx(Skeleton, { className: "h-9 w-full" })] })] }));
|
|
190
|
+
}
|
|
191
|
+
if (current === undefined) {
|
|
192
|
+
return (_jsxs("div", { ref: rootRef, tabIndex: -1, "data-slot": "fill-in-queue", "data-state": "empty", className: cn('flex flex-col gap-6 outline-none', className), children: [progressBar, _jsx(Empty, { children: _jsx(EmptyHeader, { children: _jsx(EmptyTitle, { children: empty }) }) })] }));
|
|
193
|
+
}
|
|
194
|
+
const position = `Record ${index + 1} of ${items.length}`;
|
|
195
|
+
return (_jsx(ShortcutContext.Provider, { value: register, children: _jsxs("div", { ref: rootRef, tabIndex: -1, "data-slot": "fill-in-queue", "data-state": "success", onKeyDown: onKeyDown, className: cn('flex flex-col gap-6 outline-none', className), children: [_jsxs("div", { className: "flex flex-col gap-2", children: [progressBar, _jsx("p", { className: "type-caption text-muted-foreground tabular-nums", "data-slot": "fill-in-queue-position", children: position })] }), _jsxs("div", { className: "mx-auto flex w-full max-w-[40rem] flex-col gap-6", children: [_jsx("div", { ref: recordRef, role: "group", "aria-label": position, tabIndex: -1, "data-slot": "fill-in-queue-record", className: "flex flex-col gap-6 rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", children: renderItem(current) }, currentKey ?? undefined), _jsxs("div", { className: "flex flex-col gap-2 border-t border-border pt-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(Button, { variant: "ghost", onClick: skip, disabled: saving, children: skipLabel }), _jsxs(Button, { onClick: () => void save(), disabled: !savable || saving, "aria-busy": saving || undefined, children: [saving && _jsx(Spinner, { "aria-hidden": "true", role: "presentation", "aria-label": undefined, "data-icon": "inline-start" }), saveLabel] })] }), saveError !== null && (_jsx("p", { role: "alert", className: "text-end type-caption text-destructive-text", children: saveError }))] })] })] }) }));
|
|
196
|
+
}
|
|
197
|
+
function ProgressLine({ progress }) {
|
|
198
|
+
const { done, total, label = 'filled' } = progress;
|
|
199
|
+
const share = total > 0 ? Math.min(Math.max(done / total, 0), 1) : 0;
|
|
200
|
+
const text = `${done} of ${total} ${label}`;
|
|
201
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", "data-slot": "fill-in-queue-progress", children: [_jsx("div", { role: "progressbar", "aria-label": "Progress", "aria-valuemin": 0, "aria-valuemax": total, "aria-valuenow": done, "aria-valuetext": text, className: "h-2 w-full overflow-hidden rounded-full bg-muted", children: _jsx("div", { className: "h-full rounded-full bg-primary transition-[width]", style: { width: `${share * 100}%` } }) }), _jsx("p", { className: "type-caption text-muted-foreground tabular-nums", children: text })] }));
|
|
202
|
+
}
|
|
203
|
+
/** One field of a FillInQueue record as a row of toggle buttons: one picked, or none. */
|
|
204
|
+
export function FillInChoice({ label, choices, value, onChange, suggestion, shortcut = false, className, }) {
|
|
205
|
+
const labelId = useId();
|
|
206
|
+
const register = useContext(ShortcutContext);
|
|
207
|
+
const latest = useRef({ choices, onChange });
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
latest.current = { choices, onChange };
|
|
210
|
+
});
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!shortcut || register === null)
|
|
213
|
+
return;
|
|
214
|
+
return register((digit) => {
|
|
215
|
+
const choice = latest.current.choices[digit - 1];
|
|
216
|
+
if (choice === undefined)
|
|
217
|
+
return false;
|
|
218
|
+
latest.current.onChange(choice.value);
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
}, [shortcut, register]);
|
|
222
|
+
const suggested = suggestion ? choices.find((choice) => choice.value === suggestion.value) : undefined;
|
|
223
|
+
const keys = shortcut && register !== null;
|
|
224
|
+
return (_jsxs("div", { "data-slot": "fill-in-choice", className: cn('flex flex-col gap-2', className), children: [_jsx("div", { id: labelId, className: "type-label", children: label }), _jsx("div", { role: "group", "aria-labelledby": labelId, className: "flex flex-wrap gap-2", children: choices.map((choice, index) => {
|
|
225
|
+
const pressed = value !== null && value !== undefined && choice.value === value;
|
|
226
|
+
const isSuggested = suggested !== undefined && choice.value === suggested.value;
|
|
227
|
+
return (_jsxs(Toggle, { variant: "outline", pressed: pressed, onPressedChange: () => onChange(pressed ? null : choice.value), "aria-keyshortcuts": keys && index < 9 ? String(index + 1) : undefined, "data-suggested": isSuggested || undefined, className: cn('h-auto min-h-9 px-3 py-1.5', isSuggested && 'border-dashed border-info-text', 'aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90 aria-pressed:hover:text-primary-foreground'), children: [keys && index < 9 && (_jsx("span", { "aria-hidden": "true", className: "type-caption tabular-nums text-muted-foreground group-aria-pressed/toggle:text-primary-foreground", children: index + 1 })), choice.label] }, String(choice.value)));
|
|
228
|
+
}) }), suggested !== undefined && (_jsxs("p", { className: "type-caption text-muted-foreground", children: ["Suggested: ", suggested.label, suggestion?.note !== undefined && suggestion.note !== null && _jsxs(_Fragment, { children: [" (", suggestion.note, ")"] })] }))] }));
|
|
229
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SpinWheelItem {
|
|
2
|
+
id: string | number;
|
|
3
|
+
label: string;
|
|
4
|
+
/** > 0, default 1: the slice's size and its chance (weight over the sum). */
|
|
5
|
+
weight?: number;
|
|
6
|
+
/** A tinted slice: a flagged record, a favourite. */
|
|
7
|
+
emphasis?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** An item's weight: its own when > 0 and finite, else 1. */
|
|
10
|
+
export declare const spinWheelWeight: (item: SpinWheelItem) => number;
|
|
11
|
+
/**
|
|
12
|
+
* The index of the item a value in [0, 1) lands on: each item's share of
|
|
13
|
+
* [0, 1) is its weight over the sum, in order. -1 for no items.
|
|
14
|
+
*/
|
|
15
|
+
export declare function pickSpinWheelIndex(items: readonly SpinWheelItem[], value: number): number;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SpinWheel's chance: which item a value in [0, 1) lands on, each item's
|
|
2
|
+
// share being its weight over the sum of weights.
|
|
3
|
+
/** An item's weight: its own when > 0 and finite, else 1. */
|
|
4
|
+
export const spinWheelWeight = (item) => item.weight !== undefined && Number.isFinite(item.weight) && item.weight > 0 ? item.weight : 1;
|
|
5
|
+
/**
|
|
6
|
+
* The index of the item a value in [0, 1) lands on: each item's share of
|
|
7
|
+
* [0, 1) is its weight over the sum, in order. -1 for no items.
|
|
8
|
+
*/
|
|
9
|
+
export function pickSpinWheelIndex(items, value) {
|
|
10
|
+
if (items.length === 0)
|
|
11
|
+
return -1;
|
|
12
|
+
const total = items.reduce((sum, item) => sum + spinWheelWeight(item), 0);
|
|
13
|
+
const target = Math.min(Math.max(value, 0), 1) * total;
|
|
14
|
+
let reached = 0;
|
|
15
|
+
for (let index = 0; index < items.length; index++) {
|
|
16
|
+
reached += spinWheelWeight(items[index]);
|
|
17
|
+
if (target < reached)
|
|
18
|
+
return index;
|
|
19
|
+
}
|
|
20
|
+
return items.length - 1;
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type SpinWheelItem } from './spin-wheel-pick.js';
|
|
3
|
+
export type { SpinWheelItem };
|
|
4
|
+
export interface SpinWheelProps {
|
|
5
|
+
items: readonly SpinWheelItem[];
|
|
6
|
+
/** Called when the wheel stops, with the item under the pointer. */
|
|
7
|
+
onResult: (item: SpinWheelItem) => void;
|
|
8
|
+
/** The slice drawn as picked while the wheel is at rest. The app owns it. */
|
|
9
|
+
result?: SpinWheelItem['id'] | null;
|
|
10
|
+
/** The button's label. Default "Spin". */
|
|
11
|
+
spinLabel?: string;
|
|
12
|
+
/** How long a spin turns. Default 4000. */
|
|
13
|
+
durationMs?: number;
|
|
14
|
+
/** Shown in the wheel's place, with the button disabled, when `items` is empty. */
|
|
15
|
+
emptyMessage?: ReactNode;
|
|
16
|
+
/** The source of chance, [0, 1). Default Math.random; a test passes a fixed one. */
|
|
17
|
+
random?: () => number;
|
|
18
|
+
/** Names the wheel. Default "Wheel". */
|
|
19
|
+
'aria-label'?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function SpinWheel({ items, onResult, result, spinLabel, durationMs, emptyMessage, random, 'aria-label': ariaLabel, className, }: SpinWheelProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A wheel that picks one item at random, weighted: "what do I do next?"
|
|
3
|
+
// answered by chance among the app's own candidates.
|
|
4
|
+
//
|
|
5
|
+
// - Items: `items`, each a slice in order clockwise from the pointer. Its
|
|
6
|
+
// `weight` (> 0, default 1; anything else counts as 1) is both the
|
|
7
|
+
// slice's size and its chance: weight over the sum of weights. An
|
|
8
|
+
// `emphasis` item is a tinted slice (a flagged record, a favourite).
|
|
9
|
+
// - Spinning: the Spin button turns the wheel several full turns with an
|
|
10
|
+
// ease-out and stops with the pointer inside the picked slice, a little
|
|
11
|
+
// off its centre. Spinning again starts from where it stopped. While it
|
|
12
|
+
// turns the button is disabled; when it stops, `onResult(item)` and a
|
|
13
|
+
// polite live region says "Picked <label>". With reduced motion asked
|
|
14
|
+
// for, it doesn't turn: the result is at once.
|
|
15
|
+
// - The result at rest is the app's: `result` is the id of the slice drawn
|
|
16
|
+
// picked (a solid fill), so the app can keep it, clear it, or show one
|
|
17
|
+
// it chose itself.
|
|
18
|
+
// - Empty: `emptyMessage` in the wheel's place, the button disabled.
|
|
19
|
+
//
|
|
20
|
+
// Drawn in SVG so every fill is a theme token. Labels read from the hub
|
|
21
|
+
// out, cut to fit their slice, and left off a slice too thin to read; the
|
|
22
|
+
// full label is the slice's <title>. Square, the container's width up to
|
|
23
|
+
// 28rem.
|
|
24
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
25
|
+
import { cn } from 'cn';
|
|
26
|
+
import { Button } from '../ui/button.js';
|
|
27
|
+
import { usePrefersReducedMotion } from '../../hooks/use-prefers-reduced-motion.js';
|
|
28
|
+
import { pickSpinWheelIndex, spinWheelWeight as weightOf } from './spin-wheel-pick.js';
|
|
29
|
+
// Degrees clockwise from the top, where the pointer is.
|
|
30
|
+
function slicesOf(items) {
|
|
31
|
+
const total = items.reduce((sum, item) => sum + weightOf(item), 0);
|
|
32
|
+
let at = 0;
|
|
33
|
+
return items.map((item) => {
|
|
34
|
+
const start = at;
|
|
35
|
+
at += (weightOf(item) / total) * 360;
|
|
36
|
+
return { item, start, end: at };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// The wheel in viewBox units: centred on 0, 208 across so the pointer fits.
|
|
40
|
+
const RADIUS = 96;
|
|
41
|
+
const HUB = 18;
|
|
42
|
+
const FONT = 7.5;
|
|
43
|
+
// Roughly a character's width at FONT, for cutting a label to its slice.
|
|
44
|
+
const CHAR = FONT * 0.52;
|
|
45
|
+
const RIM_GAP = 7;
|
|
46
|
+
const point = (degrees, radius) => {
|
|
47
|
+
const radians = (degrees * Math.PI) / 180;
|
|
48
|
+
return `${(radius * Math.sin(radians)).toFixed(3)} ${(-radius * Math.cos(radians)).toFixed(3)}`;
|
|
49
|
+
};
|
|
50
|
+
const slicePath = ({ start, end }) => `M 0 0 L ${point(start, RADIUS)} A ${RADIUS} ${RADIUS} 0 ${end - start > 180 ? 1 : 0} 1 ${point(end, RADIUS)} Z`;
|
|
51
|
+
// The label cut to the room its slice has: from where the slice is wide
|
|
52
|
+
// enough for a line of text out to the rim. Null when that's under three
|
|
53
|
+
// characters.
|
|
54
|
+
function fitLabel(label, degrees) {
|
|
55
|
+
const radians = (Math.min(degrees, 180) * Math.PI) / 180;
|
|
56
|
+
// The radius at which the slice is a line of text and a little wide.
|
|
57
|
+
const wideEnough = degrees >= 180 ? 0 : (FONT * 1.3) / radians;
|
|
58
|
+
const from = Math.max(HUB + 5, wideEnough);
|
|
59
|
+
const room = RADIUS - RIM_GAP - from;
|
|
60
|
+
const chars = Math.floor(room / CHAR);
|
|
61
|
+
if (chars < 3)
|
|
62
|
+
return null;
|
|
63
|
+
return label.length <= chars ? label : `${label.slice(0, chars - 1).trimEnd()}…`;
|
|
64
|
+
}
|
|
65
|
+
// How far to turn so the pointer lands at `angle` on the wheel, from
|
|
66
|
+
// `rotation`, clockwise: never backwards.
|
|
67
|
+
const turnTo = (rotation, angle) => (((-angle - rotation) % 360) + 360) % 360;
|
|
68
|
+
export function SpinWheel({ items, onResult, result = null, spinLabel = 'Spin', durationMs = 4000, emptyMessage = 'Nothing to pick from', random = Math.random, 'aria-label': ariaLabel = 'Wheel', className, }) {
|
|
69
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
70
|
+
const [rotation, setRotation] = useState(0);
|
|
71
|
+
const [spinning, setSpinning] = useState(false);
|
|
72
|
+
const [announcement, setAnnouncement] = useState('');
|
|
73
|
+
const timer = useRef(null);
|
|
74
|
+
const onResultRef = useRef(onResult);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
onResultRef.current = onResult;
|
|
77
|
+
});
|
|
78
|
+
useEffect(() => () => {
|
|
79
|
+
if (timer.current !== null)
|
|
80
|
+
clearTimeout(timer.current);
|
|
81
|
+
}, []);
|
|
82
|
+
const titleId = useId();
|
|
83
|
+
const slices = slicesOf(items);
|
|
84
|
+
const empty = items.length === 0;
|
|
85
|
+
const spin = () => {
|
|
86
|
+
if (spinning || empty)
|
|
87
|
+
return;
|
|
88
|
+
const index = pickSpinWheelIndex(items, random());
|
|
89
|
+
const slice = slices[index];
|
|
90
|
+
// Somewhere inside the slice, not on its edge.
|
|
91
|
+
const angle = slice.start + (slice.end - slice.start) * (0.15 + 0.7 * random());
|
|
92
|
+
const turns = reducedMotion ? 0 : Math.max(3, Math.round(durationMs / 800));
|
|
93
|
+
const next = rotation + turns * 360 + turnTo(rotation, angle);
|
|
94
|
+
const finish = () => {
|
|
95
|
+
timer.current = null;
|
|
96
|
+
setSpinning(false);
|
|
97
|
+
setAnnouncement(`Picked ${slice.item.label}`);
|
|
98
|
+
onResultRef.current(slice.item);
|
|
99
|
+
};
|
|
100
|
+
setRotation(next);
|
|
101
|
+
if (reducedMotion) {
|
|
102
|
+
finish();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
setAnnouncement('');
|
|
106
|
+
setSpinning(true);
|
|
107
|
+
timer.current = setTimeout(finish, durationMs);
|
|
108
|
+
};
|
|
109
|
+
return (_jsxs("div", { "data-slot": "spin-wheel", className: cn('flex w-full max-w-md flex-col items-center gap-4', className), children: [_jsx("div", { className: "relative aspect-square w-full", children: empty ? (_jsx("div", { "data-state": "empty", className: "flex size-full items-center justify-center rounded-full border-2 border-dashed border-border p-8 text-center type-body text-muted-foreground", children: emptyMessage })) : (_jsxs("svg", { viewBox: "-104 -104 208 208", role: "img", "aria-labelledby": titleId, className: "size-full select-none", "data-state": spinning ? 'spinning' : 'idle', children: [_jsx("title", { id: titleId, children: `${ariaLabel}, ${items.length} ${items.length === 1 ? 'item' : 'items'}` }), _jsxs("g", { "data-slot": "spin-wheel-rotor", style: {
|
|
110
|
+
transform: `rotate(${rotation}deg)`,
|
|
111
|
+
transformBox: 'fill-box',
|
|
112
|
+
transformOrigin: 'center',
|
|
113
|
+
transition: spinning && !reducedMotion ? `transform ${durationMs}ms cubic-bezier(0.15, 0.75, 0.2, 1)` : 'none',
|
|
114
|
+
}, children: [slices.map((slice, index) => {
|
|
115
|
+
const picked = !spinning && result !== null && slice.item.id === result;
|
|
116
|
+
const degrees = slice.end - slice.start;
|
|
117
|
+
const text = fitLabel(slice.item.label, degrees);
|
|
118
|
+
const mid = (slice.start + slice.end) / 2;
|
|
119
|
+
return (_jsxs("g", { "data-slot": "spin-wheel-slice", "data-picked": picked || undefined, "data-emphasis": slice.item.emphasis || undefined, children: [_jsx("title", { children: slice.item.label }), degrees >= 359.999 ? (_jsx("circle", { r: RADIUS, className: sliceFill(picked, slice.item.emphasis, index) })) : (_jsx("path", { d: slicePath(slice), className: cn(sliceFill(picked, slice.item.emphasis, index), 'stroke-border'), strokeWidth: 0.75 })), text !== null && (_jsx("text", { transform: `rotate(${mid - 90})`, x: RADIUS - RIM_GAP, y: 0, textAnchor: "end", dominantBaseline: "central", fontSize: FONT, className: cn(picked ? 'fill-primary-foreground font-semibold' : 'fill-foreground'), children: text }))] }, slice.item.id));
|
|
120
|
+
}), _jsx("circle", { r: RADIUS, className: "fill-none stroke-border", strokeWidth: 1.5 })] }), _jsx("circle", { r: HUB, className: "fill-background stroke-border", strokeWidth: 1.5 }), _jsx("text", { textAnchor: "middle", dominantBaseline: "central", fontSize: 11, className: "fill-foreground font-semibold tabular-nums", "aria-hidden": "true", children: items.length }), _jsx("path", { d: "M -7 -104 L 7 -104 L 0 -88 Z", className: "fill-foreground stroke-background", strokeWidth: 1.5, strokeLinejoin: "round" })] })) }), _jsx(Button, { onClick: spin, disabled: spinning || empty, size: "lg", className: "min-w-32", children: spinLabel }), _jsx("p", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })] }));
|
|
121
|
+
}
|
|
122
|
+
function sliceFill(picked, emphasis, index) {
|
|
123
|
+
if (picked)
|
|
124
|
+
return 'fill-primary';
|
|
125
|
+
if (emphasis)
|
|
126
|
+
return 'fill-info/20';
|
|
127
|
+
return index % 2 === 0 ? 'fill-muted' : 'fill-card';
|
|
128
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { AppShell, type AppShellProps, type AppShellVersion, type BottomBarItem, type NavEntry, type NavItem, type NavSection, } from './components/app/app-shell.js';
|
|
2
2
|
export type { IconComponent } from './components/app/icon-component.js';
|
|
3
3
|
export { Board, type BoardColumn, type BoardMoveTarget, type BoardProps } from './components/app/board.js';
|
|
4
|
+
export { SpinWheel, type SpinWheelProps } from './components/app/spin-wheel.js';
|
|
5
|
+
export { pickSpinWheelIndex, type SpinWheelItem } from './components/app/spin-wheel-pick.js';
|
|
6
|
+
export { FillInChoice, FillInQueue, type FillInChoiceProps, type FillInQueueProgress, type FillInQueueProps, } from './components/app/fill-in-queue.js';
|
|
4
7
|
export { Artwork, ArtworkTag, ArtworkToggle, type ArtworkAspect, type ArtworkProps, type ArtworkToggleProps, } from './components/app/artwork.js';
|
|
5
8
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
6
9
|
export { DataTable, type DataTableCardLayout, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
@@ -8,7 +11,7 @@ export { type RowGroup } from './components/app/table-groups.js';
|
|
|
8
11
|
export { byDay, type ByDayOptions } from './lib/group-by-day.js';
|
|
9
12
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
10
13
|
export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
11
|
-
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListItemProps, } from './components/app/edit-in-place.js';
|
|
14
|
+
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListAddProps, type EditListItemProps, } from './components/app/edit-in-place.js';
|
|
12
15
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
13
16
|
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
|
14
17
|
export { ErrorState } from './components/app/error-state.js';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// Composites
|
|
6
6
|
export { AppShell, } from './components/app/app-shell.js';
|
|
7
7
|
export { Board } from './components/app/board.js';
|
|
8
|
+
export { SpinWheel } from './components/app/spin-wheel.js';
|
|
9
|
+
export { pickSpinWheelIndex } from './components/app/spin-wheel-pick.js';
|
|
10
|
+
export { FillInChoice, FillInQueue, } from './components/app/fill-in-queue.js';
|
|
8
11
|
export { Artwork, ArtworkTag, ArtworkToggle, } from './components/app/artwork.js';
|
|
9
12
|
export { CountLink } from './components/app/count-link.js';
|
|
10
13
|
export { DataTable, } from './components/app/data-table.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.42.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|