@lyeve-labs/ui-kit 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/components/Autocomplete.svelte +191 -125
  2. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  3. package/dist/components/Card.svelte +43 -2
  4. package/dist/components/Card.svelte.d.ts +24 -2
  5. package/dist/components/Checkbox.svelte +174 -63
  6. package/dist/components/Checkbox.svelte.d.ts +20 -3
  7. package/dist/components/CheckboxGroup.svelte +162 -0
  8. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  9. package/dist/components/Collapsible.svelte +142 -0
  10. package/dist/components/Collapsible.svelte.d.ts +32 -0
  11. package/dist/components/CopyButton.svelte +126 -0
  12. package/dist/components/CopyButton.svelte.d.ts +14 -0
  13. package/dist/components/DatePicker.svelte +48 -6
  14. package/dist/components/DateTimePicker.svelte +337 -0
  15. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  16. package/dist/components/DescriptionList.svelte +78 -0
  17. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  18. package/dist/components/Field.svelte +104 -0
  19. package/dist/components/Field.svelte.d.ts +46 -0
  20. package/dist/components/FileInput.svelte +5 -2
  21. package/dist/components/FormMessage.svelte +85 -0
  22. package/dist/components/FormMessage.svelte.d.ts +11 -0
  23. package/dist/components/Input.svelte +1 -1
  24. package/dist/components/Label.svelte +7 -1
  25. package/dist/components/Label.svelte.d.ts +6 -0
  26. package/dist/components/MultiSelect.svelte +199 -109
  27. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  28. package/dist/components/NumberInput.svelte +8 -4
  29. package/dist/components/PageHeader.svelte +37 -4
  30. package/dist/components/PageHeader.svelte.d.ts +15 -0
  31. package/dist/components/PageShell.svelte +85 -0
  32. package/dist/components/PageShell.svelte.d.ts +38 -0
  33. package/dist/components/Pagination.svelte +43 -7
  34. package/dist/components/Panel.svelte +101 -0
  35. package/dist/components/Panel.svelte.d.ts +39 -0
  36. package/dist/components/PasswordInput.svelte +139 -0
  37. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  38. package/dist/components/Radio.svelte +152 -35
  39. package/dist/components/Radio.svelte.d.ts +16 -1
  40. package/dist/components/RadioGroup.svelte +118 -71
  41. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  42. package/dist/components/SectionHeading.svelte +39 -0
  43. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  44. package/dist/components/SegmentedControl.svelte +194 -0
  45. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  46. package/dist/components/Select.svelte +471 -46
  47. package/dist/components/Select.svelte.d.ts +95 -6
  48. package/dist/components/SidebarNav.svelte +259 -0
  49. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  50. package/dist/components/Stat.svelte +53 -2
  51. package/dist/components/Stat.svelte.d.ts +31 -0
  52. package/dist/components/Textarea.svelte +1 -1
  53. package/dist/components/TimePicker.svelte +480 -0
  54. package/dist/components/TimePicker.svelte.d.ts +23 -0
  55. package/dist/components/Toolbar.svelte +39 -0
  56. package/dist/components/Toolbar.svelte.d.ts +26 -0
  57. package/dist/components/TreeView.svelte +339 -0
  58. package/dist/components/TreeView.svelte.d.ts +37 -0
  59. package/dist/index.d.ts +25 -1
  60. package/dist/index.js +20 -1
  61. package/dist/internal/calendar.d.ts +119 -0
  62. package/dist/internal/calendar.js +225 -0
  63. package/dist/internal/choice.d.ts +136 -0
  64. package/dist/internal/choice.js +179 -0
  65. package/dist/internal/field.d.ts +31 -0
  66. package/dist/internal/field.js +38 -0
  67. package/dist/internal/filter.d.ts +80 -0
  68. package/dist/internal/filter.js +80 -0
  69. package/dist/internal/layout.d.ts +119 -0
  70. package/dist/internal/layout.js +132 -0
  71. package/dist/internal/listbox.svelte.d.ts +77 -0
  72. package/dist/internal/listbox.svelte.js +438 -0
  73. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  74. package/dist/internal/nav-expansion.svelte.js +144 -0
  75. package/dist/internal/nav-tree.d.ts +68 -0
  76. package/dist/internal/nav-tree.js +102 -0
  77. package/dist/internal/panel.d.ts +100 -0
  78. package/dist/internal/panel.js +109 -0
  79. package/dist/internal/rollup.d.ts +52 -0
  80. package/dist/internal/rollup.js +67 -0
  81. package/dist/internal/time.d.ts +103 -0
  82. package/dist/internal/time.js +166 -0
  83. package/dist/internal/tree.d.ts +86 -0
  84. package/dist/internal/tree.js +111 -0
  85. package/dist/styles/theme.css +18 -10
  86. package/package.json +4 -2
  87. 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
+ };
@@ -46,3 +46,34 @@ export declare function controlBorder(error: boolean): string;
46
46
  * reader announced the control and never the reason it was rejected.
47
47
  */
48
48
  export declare function describedBy(id: string | undefined, error?: string, hint?: string): string | undefined;
49
+ /**
50
+ * A control built from several inputs rather than from one.
51
+ *
52
+ * CONTROL_BASE is `w-full` and assumes a single input fills the control, so a
53
+ * time field composed from it stretched three two-character segments across a
54
+ * whole column and left the separators floating in the gaps. This shrink-wraps
55
+ * its content instead and keeps `h-control`, so a segmented control and an
56
+ * Input placed in the same row still line up.
57
+ *
58
+ * The focus border is driven by `focus-within`. The wrapper is a div, it never
59
+ * receives focus itself, and `focus:border-brand` on it can therefore never
60
+ * match: the control would sit at its resting border while one of its segments
61
+ * held the caret.
62
+ */
63
+ export declare const CONTROL_SEGMENTED: string;
64
+ /** As controlBorder, for a wrapper that is only ever focused through a child. */
65
+ export declare function segmentedBorder(error: boolean): string;
66
+ /**
67
+ * One segment inside CONTROL_SEGMENTED.
68
+ *
69
+ * It carries no width. A two-digit segment needs a fixed one so the control
70
+ * does not resize as the user types, and an AM/PM select needs to size to its
71
+ * own text; stating a width here would mean one of the two overriding it, and
72
+ * two width utilities on one element resolve by the order Tailwind emits them
73
+ * rather than the order they were written.
74
+ *
75
+ * The focus ring is inset. The segment sits flush inside the wrapper's border,
76
+ * so the global 2px outset outline would be drawn over that border and clipped
77
+ * by the wrapper's own rounding.
78
+ */
79
+ export declare const CONTROL_SEGMENT: string;
@@ -63,3 +63,41 @@ export function describedBy(id, error, hint) {
63
63
  return `${id}-hint`;
64
64
  return undefined;
65
65
  }
66
+ /**
67
+ * A control built from several inputs rather than from one.
68
+ *
69
+ * CONTROL_BASE is `w-full` and assumes a single input fills the control, so a
70
+ * time field composed from it stretched three two-character segments across a
71
+ * whole column and left the separators floating in the gaps. This shrink-wraps
72
+ * its content instead and keeps `h-control`, so a segmented control and an
73
+ * Input placed in the same row still line up.
74
+ *
75
+ * The focus border is driven by `focus-within`. The wrapper is a div, it never
76
+ * receives focus itself, and `focus:border-brand` on it can therefore never
77
+ * match: the control would sit at its resting border while one of its segments
78
+ * held the caret.
79
+ */
80
+ export const CONTROL_SEGMENTED = 'inline-flex h-control items-center gap-0.5 rounded-lg bg-surface-2 border px-3 ' +
81
+ 'text-sm text-fg transition-colors duration-150';
82
+ /** As controlBorder, for a wrapper that is only ever focused through a child. */
83
+ export function segmentedBorder(error) {
84
+ return error
85
+ ? 'border-danger focus-within:border-danger'
86
+ : 'border-line-strong focus-within:border-brand';
87
+ }
88
+ /**
89
+ * One segment inside CONTROL_SEGMENTED.
90
+ *
91
+ * It carries no width. A two-digit segment needs a fixed one so the control
92
+ * does not resize as the user types, and an AM/PM select needs to size to its
93
+ * own text; stating a width here would mean one of the two overriding it, and
94
+ * two width utilities on one element resolve by the order Tailwind emits them
95
+ * rather than the order they were written.
96
+ *
97
+ * The focus ring is inset. The segment sits flush inside the wrapper's border,
98
+ * so the global 2px outset outline would be drawn over that border and clipped
99
+ * by the wrapper's own rounding.
100
+ */
101
+ export const CONTROL_SEGMENT = 'shrink-0 rounded-sm border-0 bg-transparent p-0 text-center text-sm text-fg tabular-nums ' +
102
+ 'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
103
+ 'disabled:cursor-not-allowed';