@tristan2828/ui-foundation 4.51.0 → 4.53.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/design-language.md +18 -1
- package/dist/components/app/emoji-field-data.d.ts +11 -0
- package/dist/components/app/emoji-field-data.js +157 -0
- package/dist/components/app/emoji-field.d.ts +23 -0
- package/dist/components/app/emoji-field.js +30 -0
- package/dist/components/app/fill-in-field.d.ts +65 -0
- package/dist/components/app/fill-in-field.js +99 -0
- package/dist/components/app/fill-in-queue.d.ts +13 -1
- package/dist/components/app/fill-in-queue.js +15 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/package.json +1 -1
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
- [A list as cards](#a-list-as-cards) · lines 749–795
|
|
22
22
|
- [Key numbers on a record](#key-numbers-on-a-record) · lines 797–819
|
|
23
23
|
- [Picking at random](#picking-at-random) · lines 821–852
|
|
24
|
-
- [Filling in what's missing](#filling-in-whats-missing) · lines 854–
|
|
24
|
+
- [Filling in what's missing](#filling-in-whats-missing) · lines 854–921
|
|
25
25
|
|
|
26
26
|
<!-- /index -->
|
|
27
27
|
|
|
@@ -902,3 +902,20 @@ size"); the list stays where a record is edited one field at a time.
|
|
|
902
902
|
- A refused save keeps the record and says why under the buttons, not
|
|
903
903
|
in a toast. It owns its states: skeletons, `ErrorState`, and the
|
|
904
904
|
empty queue, which is the good news ("Every project has a size").
|
|
905
|
+
- **Other kinds of value** (4.53): `FillInInput` is the row for text,
|
|
906
|
+
long text, a number, money (the currency's symbol before it) or a date
|
|
907
|
+
(`DateField`), and `FillInSelect` for a list too long for buttons (a
|
|
908
|
+
reference to another record). Each has the label above and a
|
|
909
|
+
suggestion under it as a dashed "Use $249.00" button, never filled in
|
|
910
|
+
for the person.
|
|
911
|
+
- **Fields as data:** `FillInField` takes a field as an API describes it
|
|
912
|
+
(`{ name, label, kind, choices, suggestion }`, `kind` one of choice,
|
|
913
|
+
reference, text, longText, number, money, date) and draws the row its
|
|
914
|
+
kind calls for: `FillInChoice` for up to nine choices, `FillInSelect`
|
|
915
|
+
for more, `FillInInput` for the rest. Its value is a choice's value, a
|
|
916
|
+
number for number and money, text otherwise, null for none. For a page
|
|
917
|
+
that draws records it doesn't know in advance.
|
|
918
|
+
- **Records from several places:** `groups` (`{ id, label, icon,
|
|
919
|
+
progress }`) and `getGroup` name a record's source above its heading,
|
|
920
|
+
with that group's own count; with more than one group counted, each
|
|
921
|
+
one's count is listed under the bar.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type EmojiEntry = {
|
|
2
|
+
emoji: string;
|
|
3
|
+
name: string;
|
|
4
|
+
keywords?: string;
|
|
5
|
+
};
|
|
6
|
+
export type EmojiGroup = {
|
|
7
|
+
label: string;
|
|
8
|
+
emojis: EmojiEntry[];
|
|
9
|
+
};
|
|
10
|
+
export declare const EMOJI_GROUPS: EmojiGroup[];
|
|
11
|
+
export declare function filterEmojiGroups(query: string): EmojiGroup[];
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export const EMOJI_GROUPS = [
|
|
2
|
+
{
|
|
3
|
+
label: 'Morning and sleep',
|
|
4
|
+
emojis: [
|
|
5
|
+
{ emoji: '☀️', name: 'Sun', keywords: 'morning day bright' },
|
|
6
|
+
{ emoji: '🌅', name: 'Sunrise', keywords: 'morning dawn' },
|
|
7
|
+
{ emoji: '⏰', name: 'Alarm clock', keywords: 'wake time' },
|
|
8
|
+
{ emoji: '🌙', name: 'Moon', keywords: 'night evening' },
|
|
9
|
+
{ emoji: '😴', name: 'Sleeping face', keywords: 'sleep bed rest' },
|
|
10
|
+
{ emoji: '🛏️', name: 'Bed', keywords: 'sleep bedroom' },
|
|
11
|
+
{ emoji: '🌄', name: 'Sunrise over mountains', keywords: 'morning' },
|
|
12
|
+
{ emoji: '🌃', name: 'Night with stars', keywords: 'evening' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
label: 'Body and health',
|
|
17
|
+
emojis: [
|
|
18
|
+
{ emoji: '🧘', name: 'Person in lotus position', keywords: 'yoga meditate breathe calm' },
|
|
19
|
+
{ emoji: '🏃', name: 'Person running', keywords: 'run exercise workout cardio' },
|
|
20
|
+
{ emoji: '🏋️', name: 'Person lifting weights', keywords: 'gym strength workout' },
|
|
21
|
+
{ emoji: '🚴', name: 'Person biking', keywords: 'cycling exercise' },
|
|
22
|
+
{ emoji: '🏊', name: 'Person swimming', keywords: 'pool exercise' },
|
|
23
|
+
{ emoji: '🤸', name: 'Person cartwheeling', keywords: 'stretch gymnastics' },
|
|
24
|
+
{ emoji: '💪', name: 'Flexed biceps', keywords: 'strength muscle' },
|
|
25
|
+
{ emoji: '🦷', name: 'Tooth', keywords: 'dentist brush teeth' },
|
|
26
|
+
{ emoji: '🪥', name: 'Toothbrush', keywords: 'brush teeth' },
|
|
27
|
+
{ emoji: '💊', name: 'Pill', keywords: 'medicine vitamins' },
|
|
28
|
+
{ emoji: '🩺', name: 'Stethoscope', keywords: 'doctor health' },
|
|
29
|
+
{ emoji: '❤️', name: 'Red heart', keywords: 'love health' },
|
|
30
|
+
{ emoji: '🫁', name: 'Lungs', keywords: 'breathe breath' },
|
|
31
|
+
{ emoji: '🧠', name: 'Brain', keywords: 'mind think' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: 'Self-care',
|
|
36
|
+
emojis: [
|
|
37
|
+
{ emoji: '🚿', name: 'Shower', keywords: 'wash clean' },
|
|
38
|
+
{ emoji: '🛁', name: 'Bathtub', keywords: 'bath wash' },
|
|
39
|
+
{ emoji: '🧼', name: 'Soap', keywords: 'wash clean hands' },
|
|
40
|
+
{ emoji: '🧴', name: 'Lotion bottle', keywords: 'skincare moisturizer sunscreen' },
|
|
41
|
+
{ emoji: '🪒', name: 'Razor', keywords: 'shave' },
|
|
42
|
+
{ emoji: '💇', name: 'Person getting haircut', keywords: 'hair' },
|
|
43
|
+
{ emoji: '🧖', name: 'Person in steamy room', keywords: 'sauna spa relax' },
|
|
44
|
+
{ emoji: '🕯️', name: 'Candle', keywords: 'calm relax' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
label: 'Food and drink',
|
|
49
|
+
emojis: [
|
|
50
|
+
{ emoji: '🍳', name: 'Cooking', keywords: 'breakfast egg pan' },
|
|
51
|
+
{ emoji: '🥣', name: 'Bowl with spoon', keywords: 'cereal oatmeal breakfast' },
|
|
52
|
+
{ emoji: '🥗', name: 'Green salad', keywords: 'lunch healthy' },
|
|
53
|
+
{ emoji: '🍽️', name: 'Plate with cutlery', keywords: 'meal dinner eat' },
|
|
54
|
+
{ emoji: '🍎', name: 'Red apple', keywords: 'fruit snack' },
|
|
55
|
+
{ emoji: '🥑', name: 'Avocado', keywords: 'food' },
|
|
56
|
+
{ emoji: '☕', name: 'Hot beverage', keywords: 'coffee tea' },
|
|
57
|
+
{ emoji: '🍵', name: 'Teacup', keywords: 'tea' },
|
|
58
|
+
{ emoji: '💧', name: 'Droplet', keywords: 'water drink hydrate' },
|
|
59
|
+
{ emoji: '🥤', name: 'Cup with straw', keywords: 'drink' },
|
|
60
|
+
{ emoji: '🛒', name: 'Shopping cart', keywords: 'groceries store' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
label: 'Home and chores',
|
|
65
|
+
emojis: [
|
|
66
|
+
{ emoji: '🏠', name: 'House', keywords: 'home' },
|
|
67
|
+
{ emoji: '🧹', name: 'Broom', keywords: 'sweep clean chores' },
|
|
68
|
+
{ emoji: '🧽', name: 'Sponge', keywords: 'scrub clean dishes' },
|
|
69
|
+
{ emoji: '🧺', name: 'Basket', keywords: 'laundry' },
|
|
70
|
+
{ emoji: '👕', name: 'T-shirt', keywords: 'laundry clothes' },
|
|
71
|
+
{ emoji: '🗑️', name: 'Wastebasket', keywords: 'trash garbage' },
|
|
72
|
+
{ emoji: '🛠️', name: 'Hammer and wrench', keywords: 'repair fix tools' },
|
|
73
|
+
{ emoji: '🔧', name: 'Wrench', keywords: 'repair fix' },
|
|
74
|
+
{ emoji: '🔑', name: 'Key', keywords: 'lock keys' },
|
|
75
|
+
{ emoji: '🪴', name: 'Potted plant', keywords: 'garden water plants' },
|
|
76
|
+
{ emoji: '🌱', name: 'Seedling', keywords: 'garden grow plant' },
|
|
77
|
+
{ emoji: '🌿', name: 'Herb', keywords: 'garden plant' },
|
|
78
|
+
{ emoji: '🐕', name: 'Dog', keywords: 'pet walk' },
|
|
79
|
+
{ emoji: '🐈', name: 'Cat', keywords: 'pet' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
label: 'Work and learning',
|
|
84
|
+
emojis: [
|
|
85
|
+
{ emoji: '💻', name: 'Laptop', keywords: 'computer work code' },
|
|
86
|
+
{ emoji: '📧', name: 'Email', keywords: 'mail inbox' },
|
|
87
|
+
{ emoji: '📅', name: 'Calendar', keywords: 'date plan schedule' },
|
|
88
|
+
{ emoji: '📝', name: 'Memo', keywords: 'notes write journal' },
|
|
89
|
+
{ emoji: '✅', name: 'Check mark', keywords: 'done task todo' },
|
|
90
|
+
{ emoji: '📋', name: 'Clipboard', keywords: 'list checklist' },
|
|
91
|
+
{ emoji: '📚', name: 'Books', keywords: 'read study learn' },
|
|
92
|
+
{ emoji: '📖', name: 'Open book', keywords: 'read' },
|
|
93
|
+
{ emoji: '🎓', name: 'Graduation cap', keywords: 'study school learn' },
|
|
94
|
+
{ emoji: '✏️', name: 'Pencil', keywords: 'write draw' },
|
|
95
|
+
{ emoji: '🎯', name: 'Bullseye', keywords: 'goal focus target' },
|
|
96
|
+
{ emoji: '💡', name: 'Light bulb', keywords: 'idea' },
|
|
97
|
+
{ emoji: '🧾', name: 'Receipt', keywords: 'bills budget' },
|
|
98
|
+
{ emoji: '💰', name: 'Money bag', keywords: 'finance budget savings' },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
label: 'Play and music',
|
|
103
|
+
emojis: [
|
|
104
|
+
{ emoji: '🎵', name: 'Musical note', keywords: 'music song' },
|
|
105
|
+
{ emoji: '🎧', name: 'Headphones', keywords: 'music podcast listen' },
|
|
106
|
+
{ emoji: '🎸', name: 'Guitar', keywords: 'music practice' },
|
|
107
|
+
{ emoji: '🎹', name: 'Piano keyboard', keywords: 'music practice' },
|
|
108
|
+
{ emoji: '🎨', name: 'Artist palette', keywords: 'paint draw art' },
|
|
109
|
+
{ emoji: '🎮', name: 'Video game', keywords: 'play controller' },
|
|
110
|
+
{ emoji: '📺', name: 'Television', keywords: 'tv watch show' },
|
|
111
|
+
{ emoji: '🎬', name: 'Clapper board', keywords: 'movie film' },
|
|
112
|
+
{ emoji: '📷', name: 'Camera', keywords: 'photo picture' },
|
|
113
|
+
{ emoji: '🧩', name: 'Puzzle piece', keywords: 'game' },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: 'Outside and travel',
|
|
118
|
+
emojis: [
|
|
119
|
+
{ emoji: '🚶', name: 'Person walking', keywords: 'walk steps' },
|
|
120
|
+
{ emoji: '🥾', name: 'Hiking boot', keywords: 'hike trail' },
|
|
121
|
+
{ emoji: '🌳', name: 'Tree', keywords: 'park nature' },
|
|
122
|
+
{ emoji: '⛰️', name: 'Mountain', keywords: 'hike' },
|
|
123
|
+
{ emoji: '🌧️', name: 'Rain cloud', keywords: 'weather' },
|
|
124
|
+
{ emoji: '❄️', name: 'Snowflake', keywords: 'winter cold' },
|
|
125
|
+
{ emoji: '🚗', name: 'Car', keywords: 'drive commute' },
|
|
126
|
+
{ emoji: '🚌', name: 'Bus', keywords: 'commute transit' },
|
|
127
|
+
{ emoji: '✈️', name: 'Airplane', keywords: 'travel flight trip' },
|
|
128
|
+
{ emoji: '🧳', name: 'Luggage', keywords: 'travel pack trip' },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
label: 'Symbols',
|
|
133
|
+
emojis: [
|
|
134
|
+
{ emoji: '⭐', name: 'Star', keywords: 'favorite' },
|
|
135
|
+
{ emoji: '✨', name: 'Sparkles', keywords: 'new clean' },
|
|
136
|
+
{ emoji: '🔥', name: 'Fire', keywords: 'streak hot' },
|
|
137
|
+
{ emoji: '🎉', name: 'Party popper', keywords: 'celebrate' },
|
|
138
|
+
{ emoji: '🏆', name: 'Trophy', keywords: 'win goal' },
|
|
139
|
+
{ emoji: '🔔', name: 'Bell', keywords: 'reminder notify' },
|
|
140
|
+
{ emoji: '⏱️', name: 'Stopwatch', keywords: 'timer time' },
|
|
141
|
+
{ emoji: '⏳', name: 'Hourglass', keywords: 'wait time' },
|
|
142
|
+
{ emoji: '🔄', name: 'Counterclockwise arrows', keywords: 'repeat again refresh' },
|
|
143
|
+
{ emoji: '🙏', name: 'Folded hands', keywords: 'gratitude thanks pray' },
|
|
144
|
+
{ emoji: '😊', name: 'Smiling face', keywords: 'happy' },
|
|
145
|
+
{ emoji: '🌈', name: 'Rainbow', keywords: 'colour' },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
export function filterEmojiGroups(query) {
|
|
150
|
+
const q = query.trim().toLowerCase();
|
|
151
|
+
if (!q)
|
|
152
|
+
return EMOJI_GROUPS;
|
|
153
|
+
return EMOJI_GROUPS.map((group) => ({
|
|
154
|
+
...group,
|
|
155
|
+
emojis: group.emojis.filter((e) => `${e.name} ${e.keywords ?? ''}`.toLowerCase().includes(q)),
|
|
156
|
+
})).filter((group) => group.emojis.length > 0);
|
|
157
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type EmojiFieldProps = {
|
|
2
|
+
/** The trigger button's id, for the field's `<FieldLabel htmlFor>`. */
|
|
3
|
+
id?: string;
|
|
4
|
+
/** The emoji, or `''` for none. */
|
|
5
|
+
value: string;
|
|
6
|
+
/** Called with the picked emoji, or `''` when cleared. */
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
/** What the button says with none picked. Defaults to "Pick an emoji". */
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
/** Offer Clear in the popup, for an optional field. */
|
|
11
|
+
clearable?: boolean;
|
|
12
|
+
/** The value was refused: `aria-invalid` on the button. */
|
|
13
|
+
invalid?: boolean;
|
|
14
|
+
/** Saving: the button shows the emoji but won't open. */
|
|
15
|
+
readOnly?: boolean;
|
|
16
|
+
/** Open on mount (for stories and editing in place). */
|
|
17
|
+
defaultOpen?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
'aria-label'?: string;
|
|
20
|
+
'aria-labelledby'?: string;
|
|
21
|
+
'aria-describedby'?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function EmojiField({ id, value, onChange, placeholder, clearable, invalid, readOnly, defaultOpen, className, ...aria }: EmojiFieldProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// One emoji on a form: a routine's mark, a list's icon. The value is the
|
|
3
|
+
// emoji itself, or `''` for none. A button showing the choice, opening a
|
|
4
|
+
// Popover with a search box and a curated set of emoji in groups (not the
|
|
5
|
+
// whole of Unicode: `emoji-field-data.ts`). Like DateField it is one
|
|
6
|
+
// control for a react-hook-form `Controller`; `clearable` adds Clear.
|
|
7
|
+
import { useState } from 'react';
|
|
8
|
+
import { SmilePlus } from 'lucide-react';
|
|
9
|
+
import { cn } from 'cn';
|
|
10
|
+
import { Button } from '../ui/button.js';
|
|
11
|
+
import { Input } from '../ui/input.js';
|
|
12
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
|
|
13
|
+
import { filterEmojiGroups } from './emoji-field-data.js';
|
|
14
|
+
export function EmojiField({ id, value, onChange, placeholder = 'Pick an emoji', clearable = false, invalid = false, readOnly = false, defaultOpen = false, className, ...aria }) {
|
|
15
|
+
const [open, setOpen] = useState(defaultOpen && !readOnly);
|
|
16
|
+
const [query, setQuery] = useState('');
|
|
17
|
+
const groups = filterEmojiGroups(query);
|
|
18
|
+
const changeOpen = (next) => {
|
|
19
|
+
if (next && readOnly)
|
|
20
|
+
return;
|
|
21
|
+
setOpen(next);
|
|
22
|
+
if (!next)
|
|
23
|
+
setQuery('');
|
|
24
|
+
};
|
|
25
|
+
const pick = (next) => {
|
|
26
|
+
onChange(next);
|
|
27
|
+
changeOpen(false);
|
|
28
|
+
};
|
|
29
|
+
return (_jsxs(Popover, { open: open, onOpenChange: changeOpen, children: [_jsx(PopoverTrigger, { render: _jsx(Button, { id: id, type: "button", variant: "outline", "aria-invalid": invalid || undefined, "aria-readonly": readOnly || undefined, className: cn('w-full justify-start font-normal', !value && 'text-muted-foreground', className), ...aria }), children: value ? (_jsx("span", { className: "text-base leading-none", children: value })) : (_jsxs(_Fragment, { children: [_jsx(SmilePlus, { "aria-hidden": "true", className: "size-4" }), placeholder] })) }), _jsxs(PopoverContent, { className: "w-72 gap-2 p-2", align: "start", "data-slot": "emoji-field-popup", "aria-label": "Emoji", children: [_jsx(Input, { type: "search", autoComplete: "off", "aria-label": "Search emoji", placeholder: "Search", value: query, onChange: (event) => setQuery(event.target.value) }), _jsx("div", { className: "max-h-64 overflow-y-auto", children: groups.length === 0 ? (_jsxs("p", { className: "p-2 text-sm text-muted-foreground", children: ["No emoji match \"", query.trim(), "\"."] })) : (groups.map((group) => (_jsxs("section", { "aria-label": group.label, className: "mb-2 last:mb-0", children: [_jsx("h3", { className: "px-1 py-1 text-xs font-medium text-muted-foreground", children: group.label }), _jsx("div", { className: "grid grid-cols-7 gap-0.5", children: group.emojis.map((entry) => (_jsx("button", { type: "button", "aria-label": entry.name, title: entry.name, "aria-pressed": entry.emoji === value, onClick: () => pick(entry.emoji), className: "flex size-8 items-center justify-center rounded-md text-xl leading-none outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-pressed:bg-accent", children: entry.emoji }, entry.emoji))) })] }, group.label)))) }), clearable && value && (_jsx("div", { className: "border-t border-border pt-2", children: _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "w-full", onClick: () => pick(''), children: "Clear" }) }))] })] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export type FillInInputKind = 'text' | 'longText' | 'number' | 'money' | 'date';
|
|
3
|
+
export interface FillInSuggestion<V> {
|
|
4
|
+
value: V;
|
|
5
|
+
/** Why it's suggested: "from similar purchases". */
|
|
6
|
+
note?: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
export interface FillInInputProps {
|
|
9
|
+
label: string;
|
|
10
|
+
kind: FillInInputKind;
|
|
11
|
+
/** The control's text: `'YYYY-MM-DD'` for a date, a number as typed; `''` for none. */
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (value: string) => void;
|
|
14
|
+
/** Offered as a dashed "Use …" button under the control. */
|
|
15
|
+
suggestion?: FillInSuggestion<string>;
|
|
16
|
+
/** The currency of a money value, for its symbol and the suggestion. Default "USD". */
|
|
17
|
+
currency?: string;
|
|
18
|
+
locale?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
/** One text, number, money or date field of a FillInQueue record. */
|
|
23
|
+
export declare function FillInInput({ label, kind, value, onChange, suggestion, currency, locale, placeholder, className, }: FillInInputProps): import("react").JSX.Element;
|
|
24
|
+
export interface FillInSelectProps<V extends string | number> {
|
|
25
|
+
label: string;
|
|
26
|
+
choices: readonly {
|
|
27
|
+
value: V;
|
|
28
|
+
label: ReactNode;
|
|
29
|
+
}[];
|
|
30
|
+
value: V | null | undefined;
|
|
31
|
+
onChange: (value: V | null) => void;
|
|
32
|
+
suggestion?: FillInSuggestion<V>;
|
|
33
|
+
/** What the trigger says with nothing picked. Default "Pick one". */
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
/** One field of a FillInQueue record picked from a long list. */
|
|
38
|
+
export declare function FillInSelect<V extends string | number>({ label, choices, value, onChange, suggestion, placeholder, className, }: FillInSelectProps<V>): import("react").JSX.Element;
|
|
39
|
+
export type FillInFieldKind = 'choice' | 'reference' | FillInInputKind;
|
|
40
|
+
export type FillInValue = string | number | null;
|
|
41
|
+
/** A field described as data, as an API sends it. */
|
|
42
|
+
export interface FillInFieldSpec {
|
|
43
|
+
name: string;
|
|
44
|
+
label: string;
|
|
45
|
+
kind: FillInFieldKind;
|
|
46
|
+
/** For `choice` and `reference`. */
|
|
47
|
+
choices?: readonly {
|
|
48
|
+
value: string | number;
|
|
49
|
+
label: string;
|
|
50
|
+
}[];
|
|
51
|
+
suggestion?: FillInSuggestion<string | number>;
|
|
52
|
+
}
|
|
53
|
+
export interface FillInFieldProps {
|
|
54
|
+
field: FillInFieldSpec;
|
|
55
|
+
/** The draft's value: a choice's value, a number for number and money, text for the rest; null for none. */
|
|
56
|
+
value: FillInValue | undefined;
|
|
57
|
+
onChange: (value: FillInValue) => void;
|
|
58
|
+
/** The number keys pick in this field when it's drawn as a row of choices. One per record. */
|
|
59
|
+
shortcut?: boolean;
|
|
60
|
+
currency?: string;
|
|
61
|
+
locale?: string;
|
|
62
|
+
className?: string;
|
|
63
|
+
}
|
|
64
|
+
/** The row a field's kind calls for: FillInChoice, FillInSelect or FillInInput. */
|
|
65
|
+
export declare function FillInField({ field, value, onChange, shortcut, currency, locale, className }: FillInFieldProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// FillInQueue's rows for values that aren't a short choice: text, a number,
|
|
3
|
+
// money, a date, and a long list to pick from. Each looks like FillInChoice
|
|
4
|
+
// (a label, the control, a suggestion under it) so a record mixes them.
|
|
5
|
+
//
|
|
6
|
+
// - FillInInput: one text, number, money or date value. The value is the
|
|
7
|
+
// control's own text (`'YYYY-MM-DD'` for a date, `''` for none); the app
|
|
8
|
+
// reads it. A suggestion is a dashed button under it ("Use $12.00"),
|
|
9
|
+
// never filled in for the person.
|
|
10
|
+
// - FillInSelect: one value from a list too long for a row of buttons
|
|
11
|
+
// (a reference to another record, a long enum), as a Select.
|
|
12
|
+
// - FillInField: given a field as data (`{ name, label, kind, choices }`,
|
|
13
|
+
// as an API describes it), the right row: FillInChoice for up to nine
|
|
14
|
+
// choices, FillInSelect for more, FillInInput for the rest. For a page
|
|
15
|
+
// that draws records it doesn't know in advance.
|
|
16
|
+
import { useId, useState } from 'react';
|
|
17
|
+
import { cn } from 'cn';
|
|
18
|
+
import { DateField } from './date-field.js';
|
|
19
|
+
import { FillInChoice } from './fill-in-queue.js';
|
|
20
|
+
import { Button } from '../ui/button.js';
|
|
21
|
+
import { Input } from '../ui/input.js';
|
|
22
|
+
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group.js';
|
|
23
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
|
|
24
|
+
import { Textarea } from '../ui/textarea.js';
|
|
25
|
+
import { formatPlainDate } from '../../lib/plain-date.js';
|
|
26
|
+
const currencySymbol = (currency, locale) => new Intl.NumberFormat(locale, { style: 'currency', currency }).formatToParts(0).find((part) => part.type === 'currency')
|
|
27
|
+
?.value ?? currency;
|
|
28
|
+
function shown(kind, value, currency, locale) {
|
|
29
|
+
if (kind === 'date')
|
|
30
|
+
return formatPlainDate(value, { dateStyle: 'medium' }, locale);
|
|
31
|
+
const number = Number(value);
|
|
32
|
+
if (value.trim() === '' || !Number.isFinite(number))
|
|
33
|
+
return value;
|
|
34
|
+
if (kind === 'money')
|
|
35
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(number);
|
|
36
|
+
if (kind === 'number')
|
|
37
|
+
return new Intl.NumberFormat(locale).format(number);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
/** One text, number, money or date field of a FillInQueue record. */
|
|
41
|
+
export function FillInInput({ label, kind, value, onChange, suggestion, currency = 'USD', locale, placeholder, className, }) {
|
|
42
|
+
const id = useId();
|
|
43
|
+
const control = (() => {
|
|
44
|
+
switch (kind) {
|
|
45
|
+
case 'date':
|
|
46
|
+
return _jsx(DateField, { id: id, value: value, onChange: onChange, clearable: true, locale: locale });
|
|
47
|
+
case 'longText':
|
|
48
|
+
return _jsx(Textarea, { id: id, value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value) });
|
|
49
|
+
case 'money':
|
|
50
|
+
return (_jsxs(InputGroup, { children: [_jsx(InputGroupAddon, { children: _jsx(InputGroupText, { children: currencySymbol(currency, locale) }) }), _jsx(InputGroupInput, { id: id, inputMode: "decimal", value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value) })] }));
|
|
51
|
+
case 'number':
|
|
52
|
+
return (_jsx(Input, { id: id, type: "number", inputMode: "decimal", value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value) }));
|
|
53
|
+
default:
|
|
54
|
+
return _jsx(Input, { id: id, value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value) });
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
return (_jsxs("div", { "data-slot": "fill-in-input", className: cn('flex flex-col gap-2', className), children: [_jsx("label", { htmlFor: id, className: "type-label", children: label }), control, suggestion !== undefined && suggestion.value !== '' && (_jsx(SuggestionButton, { text: shown(kind, suggestion.value, currency, locale), note: suggestion.note, used: value === suggestion.value, onUse: () => onChange(suggestion.value) }))] }));
|
|
58
|
+
}
|
|
59
|
+
/** One field of a FillInQueue record picked from a long list. */
|
|
60
|
+
export function FillInSelect({ label, choices, value, onChange, suggestion, placeholder = 'Pick one', className, }) {
|
|
61
|
+
const id = useId();
|
|
62
|
+
const keyOf = (v) => String(v);
|
|
63
|
+
const byKey = new Map(choices.map((choice) => [keyOf(choice.value), choice]));
|
|
64
|
+
const suggested = suggestion ? byKey.get(keyOf(suggestion.value)) : undefined;
|
|
65
|
+
return (_jsxs("div", { "data-slot": "fill-in-select", className: cn('flex flex-col gap-2', className), children: [_jsx("label", { htmlFor: id, className: "type-label", children: label }), _jsxs(Select, { value: value === null || value === undefined ? null : keyOf(value), onValueChange: (next) => onChange(next === null ? null : (byKey.get(String(next))?.value ?? null)), children: [_jsx(SelectTrigger, { id: id, className: "w-full", children: _jsx(SelectValue, { placeholder: placeholder, children: (key) => (key === null ? placeholder : (byKey.get(key)?.label ?? key)) }) }), _jsx(SelectContent, { children: choices.map((choice) => (_jsx(SelectItem, { value: keyOf(choice.value), children: choice.label }, keyOf(choice.value)))) })] }), suggested !== undefined && (_jsx(SuggestionButton, { text: suggested.label, note: suggestion?.note, used: value !== null && value !== undefined && keyOf(value) === keyOf(suggested.value), onUse: () => onChange(suggested.value) }))] }));
|
|
66
|
+
}
|
|
67
|
+
function SuggestionButton({ text, note, used, onUse }) {
|
|
68
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", size: "sm", variant: "outline", disabled: used, onClick: onUse, "data-slot": "fill-in-suggestion", className: "border-dashed border-info-text", children: ["Use ", text] }), note !== undefined && note !== null && _jsxs("span", { className: "type-caption text-muted-foreground", children: ["(", note, ")"] })] }));
|
|
69
|
+
}
|
|
70
|
+
const isNumeric = (kind) => kind === 'number' || kind === 'money';
|
|
71
|
+
const toText = (value) => (value === null || value === undefined ? '' : String(value));
|
|
72
|
+
/** The row a field's kind calls for: FillInChoice, FillInSelect or FillInInput. */
|
|
73
|
+
export function FillInField({ field, value, onChange, shortcut, currency, locale, className }) {
|
|
74
|
+
const { kind, label, choices = [], suggestion } = field;
|
|
75
|
+
// A number's text as typed ("1." on the way to "1.5"), kept while it
|
|
76
|
+
// still reads as the draft's value; replaced when the draft changes.
|
|
77
|
+
const [text, setText] = useState(() => toText(value));
|
|
78
|
+
const keeps = value === null || value === undefined
|
|
79
|
+
? text.trim() === '' || !Number.isFinite(Number(text))
|
|
80
|
+
: text.trim() !== '' && Number(text) === value;
|
|
81
|
+
if (isNumeric(kind) && !keeps)
|
|
82
|
+
setText(toText(value));
|
|
83
|
+
if (kind === 'choice' || kind === 'reference') {
|
|
84
|
+
const pick = (next) => onChange(next);
|
|
85
|
+
const current = value === undefined ? null : value;
|
|
86
|
+
if (choices.length <= 9) {
|
|
87
|
+
return (_jsx(FillInChoice, { label: label, choices: choices, value: current, onChange: pick, suggestion: suggestion, shortcut: shortcut, className: className }));
|
|
88
|
+
}
|
|
89
|
+
return (_jsx(FillInSelect, { label: label, choices: choices, value: current, onChange: pick, suggestion: suggestion, className: className }));
|
|
90
|
+
}
|
|
91
|
+
const numeric = isNumeric(kind);
|
|
92
|
+
return (_jsx(FillInInput, { label: label, kind: kind, value: numeric ? text : toText(value), onChange: (next) => {
|
|
93
|
+
if (!numeric)
|
|
94
|
+
return onChange(next === '' ? null : next);
|
|
95
|
+
setText(next);
|
|
96
|
+
const number = Number(next);
|
|
97
|
+
onChange(next.trim() === '' || !Number.isFinite(number) ? null : number);
|
|
98
|
+
}, suggestion: suggestion === undefined ? undefined : { value: String(suggestion.value), note: suggestion.note }, currency: currency, locale: locale, className: className }));
|
|
99
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { AppError } from '../../api/contracts.js';
|
|
3
|
+
import type { IconComponent } from './icon-component.js';
|
|
3
4
|
export interface FillInQueueProgress {
|
|
4
5
|
/** Records filled so far. */
|
|
5
6
|
done: number;
|
|
@@ -8,6 +9,13 @@ export interface FillInQueueProgress {
|
|
|
8
9
|
/** The word after the count: "12 of 160 <label>". Default "filled". */
|
|
9
10
|
label?: string;
|
|
10
11
|
}
|
|
12
|
+
export interface FillInQueueGroup {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: IconComponent;
|
|
16
|
+
/** This group's own count, said beside its name on its records. */
|
|
17
|
+
progress?: Omit<FillInQueueProgress, 'label'>;
|
|
18
|
+
}
|
|
11
19
|
export interface FillInQueueProps<T> {
|
|
12
20
|
/** The records still missing something, in order. */
|
|
13
21
|
items: readonly T[];
|
|
@@ -22,6 +30,10 @@ export interface FillInQueueProps<T> {
|
|
|
22
30
|
onSkip?: (item: T) => void;
|
|
23
31
|
/** A bar and "12 of 160 filled" at the top. The app counts. */
|
|
24
32
|
progress?: FillInQueueProgress;
|
|
33
|
+
/** Where records come from: named above each record's heading. */
|
|
34
|
+
groups?: readonly FillInQueueGroup[];
|
|
35
|
+
/** The id of the record's group in `groups`. */
|
|
36
|
+
getGroup?: (item: T) => string;
|
|
25
37
|
/** Default "Save and next". */
|
|
26
38
|
saveLabel?: string;
|
|
27
39
|
/** Default "Skip". */
|
|
@@ -38,7 +50,7 @@ export interface FillInQueueProps<T> {
|
|
|
38
50
|
onIndexChange?: (index: number, item: T) => void;
|
|
39
51
|
className?: string;
|
|
40
52
|
}
|
|
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;
|
|
53
|
+
export declare function FillInQueue<T>({ items, getKey, renderItem, onSave, canSave, onSkip, progress, groups, getGroup, saveLabel, skipLabel, loading, error, onRetry, empty, index: indexProp, onIndexChange, className, }: FillInQueueProps<T>): import("react").JSX.Element;
|
|
42
54
|
export interface FillInChoiceProps<V extends string | number> {
|
|
43
55
|
/** Names the row of choices. */
|
|
44
56
|
label: string;
|
|
@@ -21,6 +21,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
21
21
|
// - Saving: the button shows it's pending; a refusal keeps the record and
|
|
22
22
|
// says why under the buttons, not in a toast.
|
|
23
23
|
// - Progress: the app counts (`progress`): a bar and "12 of 160 filled".
|
|
24
|
+
// - Groups: records from several sources (a module, a list) name theirs
|
|
25
|
+
// above the heading with `groups` and `getGroup`: its icon and label,
|
|
26
|
+
// and, when the group has its own `progress`, "3 of 12 filled" beside
|
|
27
|
+
// it. With more than one group counted, each one's count is listed
|
|
28
|
+
// under the bar.
|
|
24
29
|
// - States: `loading` (skeletons), `error` (ErrorState, with `onRetry`),
|
|
25
30
|
// and an empty queue (`empty`, default "Nothing left to fill in").
|
|
26
31
|
//
|
|
@@ -68,7 +73,7 @@ function ownsEnter(element) {
|
|
|
68
73
|
return true;
|
|
69
74
|
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
75
|
}
|
|
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, }) {
|
|
76
|
+
export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true, onSkip, progress, groups, getGroup, saveLabel = 'Save and next', skipLabel = 'Skip', loading = false, error, onRetry, empty = 'Nothing left to fill in', index: indexProp, onIndexChange, className, }) {
|
|
72
77
|
const keys = items.map(getKey);
|
|
73
78
|
const [held, setHeld] = useState(() => ({
|
|
74
79
|
key: indexProp !== undefined ? (keys[indexProp] ?? null) : null,
|
|
@@ -181,7 +186,8 @@ export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true,
|
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
};
|
|
184
|
-
const
|
|
189
|
+
const counted = groups?.filter((group) => group.progress !== undefined) ?? [];
|
|
190
|
+
const progressBar = (progress || counted.length > 1) && (_jsxs("div", { className: "flex flex-col gap-1.5", children: [progress && _jsx(ProgressLine, { progress: progress }), counted.length > 1 && (_jsx("ul", { className: "flex flex-wrap gap-x-4 gap-y-1 type-caption text-muted-foreground tabular-nums", "data-slot": "fill-in-queue-groups", children: counted.map((group) => (_jsxs("li", { children: [group.label, " ", group.progress?.done, " of ", group.progress?.total] }, group.id))) }))] }));
|
|
185
191
|
if (error !== undefined && error !== null && error !== false) {
|
|
186
192
|
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
193
|
}
|
|
@@ -192,7 +198,13 @@ export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true,
|
|
|
192
198
|
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
199
|
}
|
|
194
200
|
const position = `Record ${index + 1} of ${items.length}`;
|
|
195
|
-
|
|
201
|
+
const groupId = getGroup?.(current);
|
|
202
|
+
const group = groupId === undefined ? undefined : groups?.find((g) => g.id === groupId);
|
|
203
|
+
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: [_jsxs("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: [group && _jsx(GroupLine, { group: group, label: progress?.label }), 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 }))] })] })] }) }));
|
|
204
|
+
}
|
|
205
|
+
function GroupLine({ group, label = 'filled' }) {
|
|
206
|
+
const Icon = group.icon;
|
|
207
|
+
return (_jsxs("p", { className: "-mb-4 flex items-center gap-1.5 type-caption text-muted-foreground", "data-slot": "fill-in-queue-group", children: [Icon && _jsx(Icon, { className: "size-4", "aria-hidden": "true" }), _jsx("span", { className: "font-medium", children: group.label }), group.progress && (_jsxs("span", { className: "tabular-nums", children: ["\u00B7 ", group.progress.done, " of ", group.progress.total, " ", label] }))] }));
|
|
196
208
|
}
|
|
197
209
|
function ProgressLine({ progress }) {
|
|
198
210
|
const { done, total, label = 'filled' } = progress;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ 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
4
|
export { SpinWheel, type SpinWheelProps } from './components/app/spin-wheel.js';
|
|
5
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';
|
|
6
|
+
export { FillInChoice, FillInQueue, type FillInChoiceProps, type FillInQueueGroup, type FillInQueueProgress, type FillInQueueProps, } from './components/app/fill-in-queue.js';
|
|
7
|
+
export { FillInField, FillInInput, FillInSelect, type FillInFieldKind, type FillInFieldProps, type FillInFieldSpec, type FillInInputKind, type FillInInputProps, type FillInSelectProps, type FillInSuggestion, type FillInValue, } from './components/app/fill-in-field.js';
|
|
7
8
|
export { Artwork, ArtworkTag, ArtworkToggle, type ArtworkAspect, type ArtworkProps, type ArtworkToggleProps, } from './components/app/artwork.js';
|
|
8
9
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
9
10
|
export { DataTable, type DataTableCardLayout, type DataTableGrouping, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
@@ -41,6 +42,7 @@ export { MultiLineChart, type ChartSeries, type ChartTone, type MultiLineChartPr
|
|
|
41
42
|
export { StepBar, type StepBarProps } from './components/app/step-bar.js';
|
|
42
43
|
export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
|
|
43
44
|
export { DateField, type DateFieldProps } from './components/app/date-field.js';
|
|
45
|
+
export { EmojiField, type EmojiFieldProps } from './components/app/emoji-field.js';
|
|
44
46
|
export { ImageField, type ImageFieldProps } from './components/app/image-field.js';
|
|
45
47
|
export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
|
|
46
48
|
export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { Board } from './components/app/board.js';
|
|
|
8
8
|
export { SpinWheel } from './components/app/spin-wheel.js';
|
|
9
9
|
export { pickSpinWheelIndex } from './components/app/spin-wheel-pick.js';
|
|
10
10
|
export { FillInChoice, FillInQueue, } from './components/app/fill-in-queue.js';
|
|
11
|
+
export { FillInField, FillInInput, FillInSelect, } from './components/app/fill-in-field.js';
|
|
11
12
|
export { Artwork, ArtworkTag, ArtworkToggle, } from './components/app/artwork.js';
|
|
12
13
|
export { CountLink } from './components/app/count-link.js';
|
|
13
14
|
export { DataTable, } from './components/app/data-table.js';
|
|
@@ -44,6 +45,7 @@ export { MultiLineChart, } from './components/app/multi-line-chart.js';
|
|
|
44
45
|
export { StepBar } from './components/app/step-bar.js';
|
|
45
46
|
export { RatingStar } from './components/app/rating-star.js';
|
|
46
47
|
export { DateField } from './components/app/date-field.js';
|
|
48
|
+
export { EmojiField } from './components/app/emoji-field.js';
|
|
47
49
|
export { ImageField } from './components/app/image-field.js';
|
|
48
50
|
export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
|
|
49
51
|
export { formatPlainDate } from './lib/plain-date.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.53.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": {
|