@lyeve-labs/ui-kit 0.12.1 → 0.13.1
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/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Card.svelte +43 -2
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -63
- package/dist/components/Checkbox.svelte.d.ts +20 -3
- package/dist/components/CheckboxGroup.svelte +162 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
- package/dist/components/Collapsible.svelte +142 -0
- package/dist/components/Collapsible.svelte.d.ts +32 -0
- package/dist/components/CopyButton.svelte +126 -0
- package/dist/components/CopyButton.svelte.d.ts +14 -0
- package/dist/components/DatePicker.svelte +48 -6
- package/dist/components/DateTimePicker.svelte +337 -0
- package/dist/components/DateTimePicker.svelte.d.ts +26 -0
- package/dist/components/DescriptionList.svelte +78 -0
- package/dist/components/DescriptionList.svelte.d.ts +34 -0
- package/dist/components/Field.svelte +104 -0
- package/dist/components/Field.svelte.d.ts +46 -0
- package/dist/components/FileInput.svelte +5 -2
- package/dist/components/FormMessage.svelte +85 -0
- package/dist/components/FormMessage.svelte.d.ts +11 -0
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +7 -1
- package/dist/components/Label.svelte.d.ts +6 -0
- package/dist/components/MultiSelect.svelte +199 -109
- package/dist/components/MultiSelect.svelte.d.ts +22 -9
- package/dist/components/NumberInput.svelte +8 -4
- package/dist/components/PageHeader.svelte +37 -4
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/PageShell.svelte +85 -0
- package/dist/components/PageShell.svelte.d.ts +38 -0
- package/dist/components/Pagination.svelte +43 -7
- package/dist/components/Panel.svelte +101 -0
- package/dist/components/Panel.svelte.d.ts +39 -0
- package/dist/components/PasswordInput.svelte +139 -0
- package/dist/components/PasswordInput.svelte.d.ts +29 -0
- package/dist/components/Radio.svelte +152 -35
- package/dist/components/Radio.svelte.d.ts +16 -1
- package/dist/components/RadioGroup.svelte +118 -71
- package/dist/components/RadioGroup.svelte.d.ts +39 -9
- package/dist/components/SectionHeading.svelte +39 -0
- package/dist/components/SectionHeading.svelte.d.ts +21 -0
- package/dist/components/SegmentedControl.svelte +194 -0
- package/dist/components/SegmentedControl.svelte.d.ts +55 -0
- package/dist/components/Select.svelte +471 -46
- package/dist/components/Select.svelte.d.ts +95 -6
- package/dist/components/SidebarNav.svelte +264 -0
- package/dist/components/SidebarNav.svelte.d.ts +17 -0
- package/dist/components/Stat.svelte +53 -2
- package/dist/components/Stat.svelte.d.ts +31 -0
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +480 -0
- package/dist/components/TimePicker.svelte.d.ts +23 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +20 -1
- package/dist/internal/calendar.d.ts +119 -0
- package/dist/internal/calendar.js +225 -0
- package/dist/internal/choice.d.ts +136 -0
- package/dist/internal/choice.js +179 -0
- package/dist/internal/field.d.ts +39 -0
- package/dist/internal/field.js +48 -0
- package/dist/internal/filter.d.ts +80 -0
- package/dist/internal/filter.js +80 -0
- package/dist/internal/layout.d.ts +119 -0
- package/dist/internal/layout.js +132 -0
- package/dist/internal/listbox.svelte.d.ts +77 -0
- package/dist/internal/listbox.svelte.js +438 -0
- package/dist/internal/nav-expansion.svelte.d.ts +36 -0
- package/dist/internal/nav-expansion.svelte.js +144 -0
- package/dist/internal/nav-tree.d.ts +68 -0
- package/dist/internal/nav-tree.js +102 -0
- package/dist/internal/panel.d.ts +100 -0
- package/dist/internal/panel.js +109 -0
- package/dist/internal/rollup.d.ts +52 -0
- package/dist/internal/rollup.js +67 -0
- package/dist/internal/time.d.ts +103 -0
- package/dist/internal/time.js +166 -0
- package/dist/internal/tree.d.ts +86 -0
- package/dist/internal/tree.js +111 -0
- package/dist/styles/theme.css +18 -10
- package/package.json +4 -2
- package/src/lib/styles/theme.css +18 -10
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Month arithmetic for the date controls, done without the Date parser.
|
|
3
|
+
*
|
|
4
|
+
* The rule this module exists to enforce: no string is ever handed to the Date
|
|
5
|
+
* parser. `new Date('2026-01-02')` is UTC midnight by specification and
|
|
6
|
+
* `new Date('2026-01-02T00:00')` is local midnight, so one helper applied to a
|
|
7
|
+
* date field and to a datetime field lands in two different zones. Read the
|
|
8
|
+
* first form back through the local getters west of UTC and it is 1 January.
|
|
9
|
+
* That is the off by one day, and it appears only for the part of the world
|
|
10
|
+
* that is not on UTC, which is why it survives a developer's own testing.
|
|
11
|
+
*
|
|
12
|
+
* DatePicker binds `value` to an ISO calendar date spelled `YYYY-MM-DD`: four
|
|
13
|
+
* digit year, two digit zero padded month, two digit zero padded day, with the
|
|
14
|
+
* empty string standing for no date. Its `min` and `max` props take the same
|
|
15
|
+
* spelling and are compared as plain strings, which works only because that
|
|
16
|
+
* spelling sorts lexicographically. DateRangePicker and DateTimePicker will
|
|
17
|
+
* carry the same value and need the same arithmetic, and each of the three is a
|
|
18
|
+
* place the zone bug enters, so the arithmetic is stated once here.
|
|
19
|
+
*
|
|
20
|
+
* The numeric Date constructor is avoided as well. `new Date(24, 0, 1)` is
|
|
21
|
+
* 1924, because the constructor remaps years 0 through 99 into the twentieth
|
|
22
|
+
* century, and the year in a date field comes from a user. Everything below is
|
|
23
|
+
* integer arithmetic on the proleptic Gregorian calendar. The one Date in the
|
|
24
|
+
* file is the no argument one inside todayLocal, which is the only place a real
|
|
25
|
+
* clock is needed.
|
|
26
|
+
*
|
|
27
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
28
|
+
*/
|
|
29
|
+
/** Month lengths in a common year. February is corrected for leap years in daysInMonth. */
|
|
30
|
+
const MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
31
|
+
/** The only spelling DatePicker reads or writes. Anything else is not a date. */
|
|
32
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
33
|
+
/**
|
|
34
|
+
* The full Gregorian rule, not the every fourth year shorthand.
|
|
35
|
+
*
|
|
36
|
+
* A century is a common year unless it divides by 400. Dropping that clause
|
|
37
|
+
* makes 2000 a common year and puts 29 February 2000 out of reach of the
|
|
38
|
+
* control; keeping only the century clause makes 1900 a leap year and invents a
|
|
39
|
+
* day nobody lived through.
|
|
40
|
+
*/
|
|
41
|
+
function isLeapYear(y) {
|
|
42
|
+
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
|
43
|
+
}
|
|
44
|
+
/** The remainder operator keeps the sign of its left operand, so a negative weekday offset needs the extra turn. */
|
|
45
|
+
function mod7(n) {
|
|
46
|
+
return ((n % 7) + 7) % 7;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Days from 1970-01-01, negative before it.
|
|
50
|
+
*
|
|
51
|
+
* Howard Hinnant's days_from_civil, which is exact for every year the
|
|
52
|
+
* arithmetic can express and never touches a Date. Counting days is what makes
|
|
53
|
+
* addDays a single addition rather than a carry the caller has to write across
|
|
54
|
+
* three fields, and it is what gives firstWeekday an answer for a year under
|
|
55
|
+
* 100, where the Date constructor gives the wrong century.
|
|
56
|
+
*/
|
|
57
|
+
function toDayNumber(date) {
|
|
58
|
+
const shiftedYear = date.y - (date.m <= 2 ? 1 : 0);
|
|
59
|
+
const era = Math.floor(shiftedYear / 400);
|
|
60
|
+
const yearOfEra = shiftedYear - era * 400;
|
|
61
|
+
const dayOfYear = Math.floor((153 * (date.m + (date.m > 2 ? -3 : 9)) + 2) / 5) + date.d - 1;
|
|
62
|
+
const dayOfEra = yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear;
|
|
63
|
+
return era * 146097 + dayOfEra - 719468;
|
|
64
|
+
}
|
|
65
|
+
/** The inverse of toDayNumber, so a day count can go back to a date the grid can render. */
|
|
66
|
+
function fromDayNumber(dayNumber) {
|
|
67
|
+
const shifted = dayNumber + 719468;
|
|
68
|
+
const era = Math.floor(shifted / 146097);
|
|
69
|
+
const dayOfEra = shifted - era * 146097;
|
|
70
|
+
const yearOfEra = Math.floor((dayOfEra -
|
|
71
|
+
Math.floor(dayOfEra / 1460) +
|
|
72
|
+
Math.floor(dayOfEra / 36524) -
|
|
73
|
+
Math.floor(dayOfEra / 146096)) /
|
|
74
|
+
365);
|
|
75
|
+
const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
|
76
|
+
const monthIndex = Math.floor((5 * dayOfYear + 2) / 153);
|
|
77
|
+
const d = dayOfYear - Math.floor((153 * monthIndex + 2) / 5) + 1;
|
|
78
|
+
const m = monthIndex + (monthIndex < 10 ? 3 : -9);
|
|
79
|
+
return { y: yearOfEra + era * 400 + (m <= 2 ? 1 : 0), m, d };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Reads the `YYYY-MM-DD` DatePicker binds, or null when the string is not one.
|
|
83
|
+
*
|
|
84
|
+
* Shape checks and round trips. `2024-13-45` returns null rather than silently
|
|
85
|
+
* becoming the 2025-02-14 those three numbers normalize to in a Date: a picker
|
|
86
|
+
* that accepted it would open on a month the user never typed and report a
|
|
87
|
+
* value they never chose. A day is rejected when its month does not have it, so
|
|
88
|
+
* `2024-02-30` and `2023-02-29` are both null while `2024-02-29` is a date.
|
|
89
|
+
* Undefined and the empty string are null too, because an unset field is the
|
|
90
|
+
* ordinary state of a date input and not an error.
|
|
91
|
+
*/
|
|
92
|
+
export function parseISODate(s) {
|
|
93
|
+
if (!s)
|
|
94
|
+
return null;
|
|
95
|
+
const parts = ISO_DATE.exec(s);
|
|
96
|
+
if (!parts)
|
|
97
|
+
return null;
|
|
98
|
+
const y = Number(parts[1]);
|
|
99
|
+
const m = Number(parts[2]);
|
|
100
|
+
const d = Number(parts[3]);
|
|
101
|
+
if (m < 1 || m > 12)
|
|
102
|
+
return null;
|
|
103
|
+
if (d < 1 || d > daysInMonth(y, m))
|
|
104
|
+
return null;
|
|
105
|
+
return { y, m, d };
|
|
106
|
+
}
|
|
107
|
+
/** Pads a number to a fixed width so the result sorts as text. */
|
|
108
|
+
function pad(n, width) {
|
|
109
|
+
return String(n).padStart(width, '0');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Writes the `YYYY-MM-DD` DatePicker binds.
|
|
113
|
+
*
|
|
114
|
+
* Every part is zero padded, including the year, because the min and max
|
|
115
|
+
* comparison in the control is a string comparison: an unpadded `2024-1-5`
|
|
116
|
+
* sorts after `2024-12-01` and the bound then rejects dates inside it.
|
|
117
|
+
*/
|
|
118
|
+
export function toISODate(date) {
|
|
119
|
+
return `${pad(date.y, 4)}-${pad(date.m, 2)}-${pad(date.d, 2)}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Today in the viewer's own zone, read from the Date's local getters and never
|
|
123
|
+
* from its ISO string.
|
|
124
|
+
*
|
|
125
|
+
* `new Date().toISOString().slice(0, 10)` is the tempting one line version and
|
|
126
|
+
* it is wrong: it converts to UTC first, so a viewer in Jakarta gets yesterday
|
|
127
|
+
* until 7am and a viewer in New York gets tomorrow from the evening on. The
|
|
128
|
+
* date the calendar marks as today has to be the date on the wall behind the
|
|
129
|
+
* viewer.
|
|
130
|
+
*/
|
|
131
|
+
export function todayLocal() {
|
|
132
|
+
const now = new Date();
|
|
133
|
+
return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Days in a month, leap years included.
|
|
137
|
+
*
|
|
138
|
+
* A month outside 1 to 12 has no days and returns 0, so a caller bounding a day
|
|
139
|
+
* against it rejects the month rather than accepting a day in a month that does
|
|
140
|
+
* not exist.
|
|
141
|
+
*/
|
|
142
|
+
export function daysInMonth(y, m) {
|
|
143
|
+
if (!Number.isInteger(m) || m < 1 || m > 12)
|
|
144
|
+
return 0;
|
|
145
|
+
if (m === 2)
|
|
146
|
+
return isLeapYear(y) ? 29 : 28;
|
|
147
|
+
return MONTH_LENGTHS[m - 1];
|
|
148
|
+
}
|
|
149
|
+
/** Weekday of the first of the month, 0 for Sunday. */
|
|
150
|
+
export function firstWeekday(y, m) {
|
|
151
|
+
// Day number 0 is 1970-01-01, which was a Thursday, so the count starts four
|
|
152
|
+
// weekdays into the week.
|
|
153
|
+
return mod7(toDayNumber({ y, m, d: 1 }) + 4);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Adds months, clamping the day rather than rolling into the next month.
|
|
157
|
+
*
|
|
158
|
+
* 31 January plus one month is 28 or 29 February, never 2 or 3 March. A Date
|
|
159
|
+
* rolls, so paging a picker forward from the 31st and back again lands the user
|
|
160
|
+
* on a different day than the one they were looking at, and a range whose start
|
|
161
|
+
* was the 31st can end up after its own end.
|
|
162
|
+
*/
|
|
163
|
+
export function addMonths(date, delta) {
|
|
164
|
+
const totalMonths = date.y * 12 + (date.m - 1) + delta;
|
|
165
|
+
const y = Math.floor(totalMonths / 12);
|
|
166
|
+
const m = totalMonths - y * 12 + 1;
|
|
167
|
+
return { y, m, d: Math.min(date.d, daysInMonth(y, m)) };
|
|
168
|
+
}
|
|
169
|
+
/** Adds days across month and year boundaries, leap days included. */
|
|
170
|
+
export function addDays(date, delta) {
|
|
171
|
+
return fromDayNumber(toDayNumber(date) + delta);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Orders two dates as -1, 0 or 1.
|
|
175
|
+
*
|
|
176
|
+
* A total order, so a caller can sort and bound without constructing a Date.
|
|
177
|
+
* Returning the field difference would sort just as well and would report 2 for
|
|
178
|
+
* two years apart, which quietly breaks the caller that tests for 1.
|
|
179
|
+
*/
|
|
180
|
+
export function compareDates(a, b) {
|
|
181
|
+
if (a.y !== b.y)
|
|
182
|
+
return a.y < b.y ? -1 : 1;
|
|
183
|
+
if (a.m !== b.m)
|
|
184
|
+
return a.m < b.m ? -1 : 1;
|
|
185
|
+
if (a.d !== b.d)
|
|
186
|
+
return a.d < b.d ? -1 : 1;
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Inclusive on both ends. Either bound may be null, meaning unbounded.
|
|
191
|
+
*
|
|
192
|
+
* Inclusive because a picker given min and max for a single allowed day has to
|
|
193
|
+
* offer that day. DatePicker compares its bounds as strings today, which agrees
|
|
194
|
+
* with this only while every bound is zero padded.
|
|
195
|
+
*/
|
|
196
|
+
export function withinRange(date, min, max) {
|
|
197
|
+
if (min !== null && compareDates(date, min) < 0)
|
|
198
|
+
return false;
|
|
199
|
+
if (max !== null && compareDates(date, max) > 0)
|
|
200
|
+
return false;
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The six week grid a month renders, always 42 cells so the calendar does not
|
|
205
|
+
* change height between months. Cells outside the viewed month carry
|
|
206
|
+
* `outside: true`.
|
|
207
|
+
*
|
|
208
|
+
* DatePicker pads its lead with empty spans and stops at the last day, so
|
|
209
|
+
* February in a year where it starts on a Sunday draws four rows and a 31 day
|
|
210
|
+
* month starting on a Saturday draws six. The popover resizes as the user pages
|
|
211
|
+
* through the year and the day under the pointer moves out from under it. Fixed
|
|
212
|
+
* at 42 because six weeks is the most any month can touch.
|
|
213
|
+
*
|
|
214
|
+
* `weekStartsOn` is 0 for Sunday and 1 for Monday.
|
|
215
|
+
*/
|
|
216
|
+
export function monthGrid(y, m, weekStartsOn = 0) {
|
|
217
|
+
const lead = mod7(firstWeekday(y, m) - weekStartsOn);
|
|
218
|
+
const start = toDayNumber({ y, m, d: 1 }) - lead;
|
|
219
|
+
const cells = [];
|
|
220
|
+
for (let i = 0; i < 42; i++) {
|
|
221
|
+
const date = fromDayNumber(start + i);
|
|
222
|
+
cells.push({ date, outside: date.y !== y || date.m !== m });
|
|
223
|
+
}
|
|
224
|
+
return cells;
|
|
225
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for how a checkbox or a radio looks and announces
|
|
3
|
+
* itself.
|
|
4
|
+
*
|
|
5
|
+
* Checkbox, Radio and RadioGroup paint one control three ways. The two label
|
|
6
|
+
* wrappers hold the same utilities in two orders, Checkbox marks a required
|
|
7
|
+
* option and Radio marks nothing, RadioGroup rests its box on `border-line`
|
|
8
|
+
* where the other two use `border-line-strong`, and RadioGroup still keeps its
|
|
9
|
+
* focus ring inside the selected ternary. That last one is the defect Checkbox
|
|
10
|
+
* and Radio were already fixed for: selecting an option deleted the only
|
|
11
|
+
* indicator a keyboard user had, and in a list of a hundred options that loses
|
|
12
|
+
* your place entirely. Composing from here means a fourth choice control cannot
|
|
13
|
+
* reintroduce any of it.
|
|
14
|
+
*
|
|
15
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
16
|
+
*/
|
|
17
|
+
/** The scale of the painted box: sm 14px, md 16px, lg 20px. */
|
|
18
|
+
export type ChoiceSize = 'sm' | 'md' | 'lg';
|
|
19
|
+
/** Inline is a box beside a label. Card is a bordered option surface. */
|
|
20
|
+
export type ChoiceVariant = 'inline' | 'card';
|
|
21
|
+
/** How a group lays its options out. */
|
|
22
|
+
export type ChoiceOrientation = 'vertical' | 'horizontal';
|
|
23
|
+
/**
|
|
24
|
+
* The clickable wrapper. Card is a bordered option the whole surface of which
|
|
25
|
+
* is the target.
|
|
26
|
+
*
|
|
27
|
+
* The cursor is a ternary rather than a `cursor-pointer` that a disabled state
|
|
28
|
+
* appends `cursor-not-allowed` to. Both set the same property, so a class list
|
|
29
|
+
* carrying both resolves by stylesheet order and not by the order written here,
|
|
30
|
+
* which is how a disabled option kept offering a pointer.
|
|
31
|
+
*/
|
|
32
|
+
export declare function choiceWrap(variant: ChoiceVariant, checked: boolean, disabled: boolean): string;
|
|
33
|
+
/**
|
|
34
|
+
* The transparent input that sits over the painted box and carries every native
|
|
35
|
+
* semantic.
|
|
36
|
+
*
|
|
37
|
+
* `sr-only` leaves the input 1x1 and buried under the box that replaces it, so
|
|
38
|
+
* a click aimed at what the user sees lands on nothing. It covers the box
|
|
39
|
+
* instead, and `peer` is what lets the box below read its focus state.
|
|
40
|
+
*/
|
|
41
|
+
export declare const CHOICE_INPUT = "peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed";
|
|
42
|
+
/**
|
|
43
|
+
* The painted box or circle. Square for a checkbox, round for a radio, sized by
|
|
44
|
+
* ChoiceSize.
|
|
45
|
+
*
|
|
46
|
+
* The resting border is `border-line-strong`. `border-line` reads 1.25:1
|
|
47
|
+
* against the page, and the border is the only thing identifying an empty
|
|
48
|
+
* checkbox as a control, so at that contrast the control is invisible until it
|
|
49
|
+
* is used. A radio keeps a 2px ring because its selected state is a dot inside
|
|
50
|
+
* the ring rather than a fill, and a 1px ring reads as a smudge beside the dot
|
|
51
|
+
* at 14px.
|
|
52
|
+
*
|
|
53
|
+
* `mixed` paints exactly what `checked` paints. A part-selected parent is on,
|
|
54
|
+
* not a third state with a colour of its own; only the mark it holds differs.
|
|
55
|
+
*/
|
|
56
|
+
export declare function choiceBox(kind: 'checkbox' | 'radio', size: ChoiceSize, checked: boolean, mixed: boolean): string;
|
|
57
|
+
/**
|
|
58
|
+
* The focus ring, stated ONCE and outside every checked branch.
|
|
59
|
+
*
|
|
60
|
+
* The ring lived inside the checked ternary in three components, so choosing an
|
|
61
|
+
* option removed the only indicator a keyboard user had. It is a ring and not a
|
|
62
|
+
* border colour because a selected box already carries a brand border, which
|
|
63
|
+
* leaves a border-only focus state with nothing to say.
|
|
64
|
+
*/
|
|
65
|
+
export declare const CHOICE_FOCUS = "peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-brand peer-focus-visible:outline-offset-2";
|
|
66
|
+
/**
|
|
67
|
+
* The label stack beside or under the box.
|
|
68
|
+
*
|
|
69
|
+
* Checkbox and Radio both build this, in different word orders, which is how
|
|
70
|
+
* two controls in one form ended up with different gaps between a label and its
|
|
71
|
+
* description.
|
|
72
|
+
*/
|
|
73
|
+
export declare const CHOICE_LABEL_STACK = "flex flex-col gap-0.5";
|
|
74
|
+
/**
|
|
75
|
+
* The label text, sized with the control.
|
|
76
|
+
*
|
|
77
|
+
* An option label is not a field label: it names one choice rather than the
|
|
78
|
+
* group, so it stays regular weight and FIELD_LABEL keeps the medium.
|
|
79
|
+
*/
|
|
80
|
+
export declare function choiceLabel(size: ChoiceSize): string;
|
|
81
|
+
/**
|
|
82
|
+
* Secondary line under the label.
|
|
83
|
+
*
|
|
84
|
+
* The same class the hint under a text field uses, taken from field.ts rather
|
|
85
|
+
* than respelled, because a checkbox hint and an input hint sitting in one form
|
|
86
|
+
* have no reason to differ and had already drifted apart once.
|
|
87
|
+
*/
|
|
88
|
+
export declare const CHOICE_DESCRIPTION = "text-xs text-faint";
|
|
89
|
+
/**
|
|
90
|
+
* The icon slot ahead of the label.
|
|
91
|
+
*
|
|
92
|
+
* Fixed to the box size so the label starts at the same x in every row of a
|
|
93
|
+
* list, and given no colour of its own so it inherits the label and dims with
|
|
94
|
+
* the wrapper when the option is disabled.
|
|
95
|
+
*/
|
|
96
|
+
export declare function choiceIcon(size: ChoiceSize): string;
|
|
97
|
+
/**
|
|
98
|
+
* Pixel size for a lucide icon at each ChoiceSize, so a consumer does not
|
|
99
|
+
* guess.
|
|
100
|
+
*
|
|
101
|
+
* A lucide icon takes a number, not a class, so the slot above cannot size it.
|
|
102
|
+
* These are the same 14, 16 and 20 the slot reserves; an icon rendered at any
|
|
103
|
+
* other size overflows the slot or floats inside it.
|
|
104
|
+
*/
|
|
105
|
+
export declare const CHOICE_ICON_PX: Record<ChoiceSize, number>;
|
|
106
|
+
/**
|
|
107
|
+
* The fieldset a group renders, so the set is announced as a group rather than
|
|
108
|
+
* as loose controls.
|
|
109
|
+
*
|
|
110
|
+
* `min-w-0` is load bearing: a fieldset defaults to `min-width: min-content`,
|
|
111
|
+
* so one long option label widened the whole group past its column instead of
|
|
112
|
+
* wrapping.
|
|
113
|
+
*/
|
|
114
|
+
export declare const CHOICE_GROUP = "flex min-w-0 flex-col gap-2";
|
|
115
|
+
/**
|
|
116
|
+
* The row or column the options sit in.
|
|
117
|
+
*
|
|
118
|
+
* A horizontal group gets a wider inline gap than block gap: options read as
|
|
119
|
+
* separate choices across a row and as one list down a column, and an equal gap
|
|
120
|
+
* in both axes makes a wrapped row look like a grid.
|
|
121
|
+
*/
|
|
122
|
+
export declare function choiceGroupList(orientation: ChoiceOrientation): string;
|
|
123
|
+
/**
|
|
124
|
+
* The check and the mixed marks, as SVG path data on the box's own grid.
|
|
125
|
+
*
|
|
126
|
+
* Drawn as paths because the consistency suite rejects a Unicode check outright,
|
|
127
|
+
* and because a font glyph lands at a different optical weight from every other
|
|
128
|
+
* icon in the library. Both sit in a 10 by 8 viewBox centred in the box, at
|
|
129
|
+
* stroke width 1.5 with round caps, so the two marks swap without the box
|
|
130
|
+
* shifting. The mixed bar stops short of the edges so its round caps do not
|
|
131
|
+
* touch the border.
|
|
132
|
+
*/
|
|
133
|
+
export declare const CHOICE_MARK: {
|
|
134
|
+
check: string;
|
|
135
|
+
mixed: string;
|
|
136
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for how a checkbox or a radio looks and announces
|
|
3
|
+
* itself.
|
|
4
|
+
*
|
|
5
|
+
* Checkbox, Radio and RadioGroup paint one control three ways. The two label
|
|
6
|
+
* wrappers hold the same utilities in two orders, Checkbox marks a required
|
|
7
|
+
* option and Radio marks nothing, RadioGroup rests its box on `border-line`
|
|
8
|
+
* where the other two use `border-line-strong`, and RadioGroup still keeps its
|
|
9
|
+
* focus ring inside the selected ternary. That last one is the defect Checkbox
|
|
10
|
+
* and Radio were already fixed for: selecting an option deleted the only
|
|
11
|
+
* indicator a keyboard user had, and in a list of a hundred options that loses
|
|
12
|
+
* your place entirely. Composing from here means a fourth choice control cannot
|
|
13
|
+
* reintroduce any of it.
|
|
14
|
+
*
|
|
15
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
16
|
+
*/
|
|
17
|
+
import { FIELD_HINT } from './field.js';
|
|
18
|
+
/**
|
|
19
|
+
* The box and the icon slot share one size map, so the label text starts at the
|
|
20
|
+
* same x whether or not an option carries an icon. 3.5 is 14px, 4 is 16px and 5
|
|
21
|
+
* is 20px on the 4px grid.
|
|
22
|
+
*/
|
|
23
|
+
const CHOICE_SQUARE = {
|
|
24
|
+
sm: 'h-3.5 w-3.5',
|
|
25
|
+
md: 'h-4 w-4',
|
|
26
|
+
lg: 'h-5 w-5',
|
|
27
|
+
};
|
|
28
|
+
const WRAP_INLINE = 'inline-flex select-none items-start gap-2.5';
|
|
29
|
+
const WRAP_CARD = 'flex select-none items-start gap-2.5 rounded-lg border p-3 transition-colors duration-150';
|
|
30
|
+
/**
|
|
31
|
+
* The clickable wrapper. Card is a bordered option the whole surface of which
|
|
32
|
+
* is the target.
|
|
33
|
+
*
|
|
34
|
+
* The cursor is a ternary rather than a `cursor-pointer` that a disabled state
|
|
35
|
+
* appends `cursor-not-allowed` to. Both set the same property, so a class list
|
|
36
|
+
* carrying both resolves by stylesheet order and not by the order written here,
|
|
37
|
+
* which is how a disabled option kept offering a pointer.
|
|
38
|
+
*/
|
|
39
|
+
export function choiceWrap(variant, checked, disabled) {
|
|
40
|
+
const cursor = disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer';
|
|
41
|
+
if (variant === 'card') {
|
|
42
|
+
// A selected card is tinted rather than filled: the label sits on it, and
|
|
43
|
+
// solid brand behind body text drops the contrast below AA.
|
|
44
|
+
const paint = checked ? 'border-brand bg-brand/8' : 'border-line-strong bg-surface-2';
|
|
45
|
+
return `${WRAP_CARD} ${paint} ${cursor}`;
|
|
46
|
+
}
|
|
47
|
+
return `${WRAP_INLINE} ${cursor}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The transparent input that sits over the painted box and carries every native
|
|
51
|
+
* semantic.
|
|
52
|
+
*
|
|
53
|
+
* `sr-only` leaves the input 1x1 and buried under the box that replaces it, so
|
|
54
|
+
* a click aimed at what the user sees lands on nothing. It covers the box
|
|
55
|
+
* instead, and `peer` is what lets the box below read its focus state.
|
|
56
|
+
*/
|
|
57
|
+
export const CHOICE_INPUT = 'peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed';
|
|
58
|
+
const BOX_BASE = 'pointer-events-none flex shrink-0 items-center justify-center transition-colors duration-150';
|
|
59
|
+
/**
|
|
60
|
+
* The painted box or circle. Square for a checkbox, round for a radio, sized by
|
|
61
|
+
* ChoiceSize.
|
|
62
|
+
*
|
|
63
|
+
* The resting border is `border-line-strong`. `border-line` reads 1.25:1
|
|
64
|
+
* against the page, and the border is the only thing identifying an empty
|
|
65
|
+
* checkbox as a control, so at that contrast the control is invisible until it
|
|
66
|
+
* is used. A radio keeps a 2px ring because its selected state is a dot inside
|
|
67
|
+
* the ring rather than a fill, and a 1px ring reads as a smudge beside the dot
|
|
68
|
+
* at 14px.
|
|
69
|
+
*
|
|
70
|
+
* `mixed` paints exactly what `checked` paints. A part-selected parent is on,
|
|
71
|
+
* not a third state with a colour of its own; only the mark it holds differs.
|
|
72
|
+
*/
|
|
73
|
+
export function choiceBox(kind, size, checked, mixed) {
|
|
74
|
+
const on = checked || mixed;
|
|
75
|
+
const shape = kind === 'radio' ? 'rounded-full border-2' : 'rounded-xs border';
|
|
76
|
+
const paint = on
|
|
77
|
+
? kind === 'radio'
|
|
78
|
+
? 'border-brand bg-surface-2'
|
|
79
|
+
: 'border-brand bg-brand text-ink'
|
|
80
|
+
: 'border-line-strong bg-surface-2';
|
|
81
|
+
return `${BOX_BASE} ${CHOICE_SQUARE[size]} ${shape} ${paint} ${CHOICE_FOCUS}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The focus ring, stated ONCE and outside every checked branch.
|
|
85
|
+
*
|
|
86
|
+
* The ring lived inside the checked ternary in three components, so choosing an
|
|
87
|
+
* option removed the only indicator a keyboard user had. It is a ring and not a
|
|
88
|
+
* border colour because a selected box already carries a brand border, which
|
|
89
|
+
* leaves a border-only focus state with nothing to say.
|
|
90
|
+
*/
|
|
91
|
+
export const CHOICE_FOCUS = 'peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-brand peer-focus-visible:outline-offset-2';
|
|
92
|
+
/**
|
|
93
|
+
* The label stack beside or under the box.
|
|
94
|
+
*
|
|
95
|
+
* Checkbox and Radio both build this, in different word orders, which is how
|
|
96
|
+
* two controls in one form ended up with different gaps between a label and its
|
|
97
|
+
* description.
|
|
98
|
+
*/
|
|
99
|
+
export const CHOICE_LABEL_STACK = 'flex flex-col gap-0.5';
|
|
100
|
+
/**
|
|
101
|
+
* The label text, sized with the control.
|
|
102
|
+
*
|
|
103
|
+
* An option label is not a field label: it names one choice rather than the
|
|
104
|
+
* group, so it stays regular weight and FIELD_LABEL keeps the medium.
|
|
105
|
+
*/
|
|
106
|
+
export function choiceLabel(size) {
|
|
107
|
+
const scale = {
|
|
108
|
+
sm: 'text-xs',
|
|
109
|
+
md: 'text-sm',
|
|
110
|
+
lg: 'text-base',
|
|
111
|
+
};
|
|
112
|
+
return `${scale[size]} text-fg`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Secondary line under the label.
|
|
116
|
+
*
|
|
117
|
+
* The same class the hint under a text field uses, taken from field.ts rather
|
|
118
|
+
* than respelled, because a checkbox hint and an input hint sitting in one form
|
|
119
|
+
* have no reason to differ and had already drifted apart once.
|
|
120
|
+
*/
|
|
121
|
+
export const CHOICE_DESCRIPTION = FIELD_HINT;
|
|
122
|
+
/**
|
|
123
|
+
* The icon slot ahead of the label.
|
|
124
|
+
*
|
|
125
|
+
* Fixed to the box size so the label starts at the same x in every row of a
|
|
126
|
+
* list, and given no colour of its own so it inherits the label and dims with
|
|
127
|
+
* the wrapper when the option is disabled.
|
|
128
|
+
*/
|
|
129
|
+
export function choiceIcon(size) {
|
|
130
|
+
return `flex shrink-0 items-center justify-center ${CHOICE_SQUARE[size]}`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Pixel size for a lucide icon at each ChoiceSize, so a consumer does not
|
|
134
|
+
* guess.
|
|
135
|
+
*
|
|
136
|
+
* A lucide icon takes a number, not a class, so the slot above cannot size it.
|
|
137
|
+
* These are the same 14, 16 and 20 the slot reserves; an icon rendered at any
|
|
138
|
+
* other size overflows the slot or floats inside it.
|
|
139
|
+
*/
|
|
140
|
+
export const CHOICE_ICON_PX = {
|
|
141
|
+
sm: 14,
|
|
142
|
+
md: 16,
|
|
143
|
+
lg: 20,
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* The fieldset a group renders, so the set is announced as a group rather than
|
|
147
|
+
* as loose controls.
|
|
148
|
+
*
|
|
149
|
+
* `min-w-0` is load bearing: a fieldset defaults to `min-width: min-content`,
|
|
150
|
+
* so one long option label widened the whole group past its column instead of
|
|
151
|
+
* wrapping.
|
|
152
|
+
*/
|
|
153
|
+
export const CHOICE_GROUP = 'flex min-w-0 flex-col gap-2';
|
|
154
|
+
/**
|
|
155
|
+
* The row or column the options sit in.
|
|
156
|
+
*
|
|
157
|
+
* A horizontal group gets a wider inline gap than block gap: options read as
|
|
158
|
+
* separate choices across a row and as one list down a column, and an equal gap
|
|
159
|
+
* in both axes makes a wrapped row look like a grid.
|
|
160
|
+
*/
|
|
161
|
+
export function choiceGroupList(orientation) {
|
|
162
|
+
return orientation === 'horizontal'
|
|
163
|
+
? 'flex flex-row flex-wrap gap-x-5 gap-y-2'
|
|
164
|
+
: 'flex flex-col gap-2';
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The check and the mixed marks, as SVG path data on the box's own grid.
|
|
168
|
+
*
|
|
169
|
+
* Drawn as paths because the consistency suite rejects a Unicode check outright,
|
|
170
|
+
* and because a font glyph lands at a different optical weight from every other
|
|
171
|
+
* icon in the library. Both sit in a 10 by 8 viewBox centred in the box, at
|
|
172
|
+
* stroke width 1.5 with round caps, so the two marks swap without the box
|
|
173
|
+
* shifting. The mixed bar stops short of the edges so its round caps do not
|
|
174
|
+
* touch the border.
|
|
175
|
+
*/
|
|
176
|
+
export const CHOICE_MARK = {
|
|
177
|
+
check: 'M1 4l3 3 5-6',
|
|
178
|
+
mixed: 'M1.5 4h7',
|
|
179
|
+
};
|
package/dist/internal/field.d.ts
CHANGED
|
@@ -25,6 +25,14 @@ export declare const FIELD_ERROR = "text-xs text-danger";
|
|
|
25
25
|
* MultiSelect chip well - use `CONTROL_MULTILINE` instead and keep the token as
|
|
26
26
|
* a minimum.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* outline-none must be paired with a replacement. It was not here: theme.css
|
|
30
|
+
* declares a global :focus-visible outline of 2px solid brand, chosen for its
|
|
31
|
+
* contrast, and a utility beats the base layer, so these two cancelled the
|
|
32
|
+
* kit's own focus indicator for every text input, textarea, number and select.
|
|
33
|
+
* Focus was left as a 1px border-colour change. SidebarNav's buttons and
|
|
34
|
+
* CONTROL_SEGMENT already pair the two correctly; these did half of it.
|
|
35
|
+
*/
|
|
28
36
|
export declare const CONTROL_BASE: string;
|
|
29
37
|
/** As CONTROL_BASE, for controls whose height is driven by their content. */
|
|
30
38
|
export declare const CONTROL_MULTILINE: string;
|
|
@@ -46,3 +54,34 @@ export declare function controlBorder(error: boolean): string;
|
|
|
46
54
|
* reader announced the control and never the reason it was rejected.
|
|
47
55
|
*/
|
|
48
56
|
export declare function describedBy(id: string | undefined, error?: string, hint?: string): string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* A control built from several inputs rather than from one.
|
|
59
|
+
*
|
|
60
|
+
* CONTROL_BASE is `w-full` and assumes a single input fills the control, so a
|
|
61
|
+
* time field composed from it stretched three two-character segments across a
|
|
62
|
+
* whole column and left the separators floating in the gaps. This shrink-wraps
|
|
63
|
+
* its content instead and keeps `h-control`, so a segmented control and an
|
|
64
|
+
* Input placed in the same row still line up.
|
|
65
|
+
*
|
|
66
|
+
* The focus border is driven by `focus-within`. The wrapper is a div, it never
|
|
67
|
+
* receives focus itself, and `focus:border-brand` on it can therefore never
|
|
68
|
+
* match: the control would sit at its resting border while one of its segments
|
|
69
|
+
* held the caret.
|
|
70
|
+
*/
|
|
71
|
+
export declare const CONTROL_SEGMENTED: string;
|
|
72
|
+
/** As controlBorder, for a wrapper that is only ever focused through a child. */
|
|
73
|
+
export declare function segmentedBorder(error: boolean): string;
|
|
74
|
+
/**
|
|
75
|
+
* One segment inside CONTROL_SEGMENTED.
|
|
76
|
+
*
|
|
77
|
+
* It carries no width. A two-digit segment needs a fixed one so the control
|
|
78
|
+
* does not resize as the user types, and an AM/PM select needs to size to its
|
|
79
|
+
* own text; stating a width here would mean one of the two overriding it, and
|
|
80
|
+
* two width utilities on one element resolve by the order Tailwind emits them
|
|
81
|
+
* rather than the order they were written.
|
|
82
|
+
*
|
|
83
|
+
* The focus ring is inset. The segment sits flush inside the wrapper's border,
|
|
84
|
+
* so the global 2px outset outline would be drawn over that border and clipped
|
|
85
|
+
* by the wrapper's own rounding.
|
|
86
|
+
*/
|
|
87
|
+
export declare const CONTROL_SEGMENT: string;
|
package/dist/internal/field.js
CHANGED
|
@@ -25,12 +25,22 @@ export const FIELD_ERROR = 'text-xs text-danger';
|
|
|
25
25
|
* MultiSelect chip well - use `CONTROL_MULTILINE` instead and keep the token as
|
|
26
26
|
* a minimum.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* outline-none must be paired with a replacement. It was not here: theme.css
|
|
30
|
+
* declares a global :focus-visible outline of 2px solid brand, chosen for its
|
|
31
|
+
* contrast, and a utility beats the base layer, so these two cancelled the
|
|
32
|
+
* kit's own focus indicator for every text input, textarea, number and select.
|
|
33
|
+
* Focus was left as a 1px border-colour change. SidebarNav's buttons and
|
|
34
|
+
* CONTROL_SEGMENT already pair the two correctly; these did half of it.
|
|
35
|
+
*/
|
|
28
36
|
export const CONTROL_BASE = 'w-full h-control rounded-lg bg-surface-2 border px-3 text-sm text-fg ' +
|
|
29
37
|
'placeholder:text-faint transition-colors duration-150 outline-none ' +
|
|
38
|
+
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
|
|
30
39
|
'disabled:opacity-50 disabled:cursor-not-allowed';
|
|
31
40
|
/** As CONTROL_BASE, for controls whose height is driven by their content. */
|
|
32
41
|
export const CONTROL_MULTILINE = 'w-full min-h-control rounded-lg bg-surface-2 border px-3 py-2 text-sm text-fg ' +
|
|
33
42
|
'placeholder:text-faint transition-colors duration-150 outline-none ' +
|
|
43
|
+
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
|
|
34
44
|
'disabled:opacity-50 disabled:cursor-not-allowed';
|
|
35
45
|
/**
|
|
36
46
|
* The border a control carries at rest and on focus.
|
|
@@ -63,3 +73,41 @@ export function describedBy(id, error, hint) {
|
|
|
63
73
|
return `${id}-hint`;
|
|
64
74
|
return undefined;
|
|
65
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* A control built from several inputs rather than from one.
|
|
78
|
+
*
|
|
79
|
+
* CONTROL_BASE is `w-full` and assumes a single input fills the control, so a
|
|
80
|
+
* time field composed from it stretched three two-character segments across a
|
|
81
|
+
* whole column and left the separators floating in the gaps. This shrink-wraps
|
|
82
|
+
* its content instead and keeps `h-control`, so a segmented control and an
|
|
83
|
+
* Input placed in the same row still line up.
|
|
84
|
+
*
|
|
85
|
+
* The focus border is driven by `focus-within`. The wrapper is a div, it never
|
|
86
|
+
* receives focus itself, and `focus:border-brand` on it can therefore never
|
|
87
|
+
* match: the control would sit at its resting border while one of its segments
|
|
88
|
+
* held the caret.
|
|
89
|
+
*/
|
|
90
|
+
export const CONTROL_SEGMENTED = 'inline-flex h-control items-center gap-0.5 rounded-lg bg-surface-2 border px-3 ' +
|
|
91
|
+
'text-sm text-fg transition-colors duration-150';
|
|
92
|
+
/** As controlBorder, for a wrapper that is only ever focused through a child. */
|
|
93
|
+
export function segmentedBorder(error) {
|
|
94
|
+
return error
|
|
95
|
+
? 'border-danger focus-within:border-danger'
|
|
96
|
+
: 'border-line-strong focus-within:border-brand';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One segment inside CONTROL_SEGMENTED.
|
|
100
|
+
*
|
|
101
|
+
* It carries no width. A two-digit segment needs a fixed one so the control
|
|
102
|
+
* does not resize as the user types, and an AM/PM select needs to size to its
|
|
103
|
+
* own text; stating a width here would mean one of the two overriding it, and
|
|
104
|
+
* two width utilities on one element resolve by the order Tailwind emits them
|
|
105
|
+
* rather than the order they were written.
|
|
106
|
+
*
|
|
107
|
+
* The focus ring is inset. The segment sits flush inside the wrapper's border,
|
|
108
|
+
* so the global 2px outset outline would be drawn over that border and clipped
|
|
109
|
+
* by the wrapper's own rounding.
|
|
110
|
+
*/
|
|
111
|
+
export const CONTROL_SEGMENT = 'shrink-0 rounded-sm border-0 bg-transparent p-0 text-center text-sm text-fg tabular-nums ' +
|
|
112
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
|
|
113
|
+
'disabled:cursor-not-allowed';
|