@robr0/design-system 0.7.0 → 0.8.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.
@@ -0,0 +1,235 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import React, { useState, useMemo } from "react";
3
+ import { Table } from "../Table/Table.js";
4
+ import { Pagination } from "../Pagination/Pagination.js";
5
+ import { Checkbox } from "../Checkbox/Checkbox.js";
6
+ import { Input } from "../Input/Input.js";
7
+ import { EmptyState } from "../EmptyState/EmptyState.js";
8
+ import "./DataTable.css";
9
+ import "../../fonts/material-symbols.css";
10
+ const compareValues = (a, b) => {
11
+ if (a == null && b == null) return 0;
12
+ if (a == null) return -1;
13
+ if (b == null) return 1;
14
+ if (typeof a === "number" && typeof b === "number") return a - b;
15
+ return String(a).localeCompare(String(b), void 0, { numeric: true, sensitivity: "base" });
16
+ };
17
+ const DataTable = React.forwardRef(
18
+ ({
19
+ columns,
20
+ rows,
21
+ pageSize,
22
+ selectable = false,
23
+ selectedIds,
24
+ defaultSelectedIds,
25
+ onSelectionChange,
26
+ sort,
27
+ defaultSort,
28
+ onSortChange,
29
+ searchable = false,
30
+ searchPlaceholder = "Search",
31
+ toolbar,
32
+ size = "default",
33
+ striped = false,
34
+ caption,
35
+ emptyState,
36
+ className = "",
37
+ ...rest
38
+ }, ref) => {
39
+ const baseClass = "ds-data-table";
40
+ const [search, setSearch] = useState("");
41
+ const [page, setPage] = useState(1);
42
+ const isSortControlled = sort !== void 0;
43
+ const [uncontrolledSort, setUncontrolledSort] = useState(
44
+ defaultSort ?? null
45
+ );
46
+ const currentSort = isSortControlled ? sort : uncontrolledSort;
47
+ const isSelectionControlled = selectedIds !== void 0;
48
+ const [uncontrolledSelection, setUncontrolledSelection] = useState(
49
+ defaultSelectedIds ?? []
50
+ );
51
+ const currentSelection = isSelectionControlled ? selectedIds : uncontrolledSelection;
52
+ const setSelection = (ids) => {
53
+ if (!isSelectionControlled) setUncontrolledSelection(ids);
54
+ onSelectionChange?.(ids);
55
+ };
56
+ const cycleSort = (key) => {
57
+ let next;
58
+ if (currentSort?.key !== key) next = { key, direction: "asc" };
59
+ else if (currentSort.direction === "asc") next = { key, direction: "desc" };
60
+ else next = null;
61
+ if (!isSortControlled) setUncontrolledSort(next);
62
+ onSortChange?.(next);
63
+ };
64
+ const filteredRows = useMemo(() => {
65
+ if (!search.trim()) return rows;
66
+ const needle = search.trim().toLowerCase();
67
+ return rows.filter(
68
+ (row) => Object.values(row.values).some(
69
+ (value) => value != null && String(value).toLowerCase().includes(needle)
70
+ )
71
+ );
72
+ }, [rows, search]);
73
+ const sortedRows = useMemo(() => {
74
+ if (!currentSort) return filteredRows;
75
+ const { key, direction } = currentSort;
76
+ const factor = direction === "asc" ? 1 : -1;
77
+ return [...filteredRows].sort(
78
+ (a, b) => compareValues(a.values[key], b.values[key]) * factor
79
+ );
80
+ }, [filteredRows, currentSort]);
81
+ const pageCount = pageSize ? Math.max(1, Math.ceil(sortedRows.length / pageSize)) : 1;
82
+ const clampedPage = Math.min(page, pageCount);
83
+ const visibleRows = pageSize ? sortedRows.slice((clampedPage - 1) * pageSize, clampedPage * pageSize) : sortedRows;
84
+ const visibleIds = visibleRows.map((row) => row.id);
85
+ const selectedVisible = visibleIds.filter((id) => currentSelection.includes(id));
86
+ const allVisibleSelected = visibleIds.length > 0 && selectedVisible.length === visibleIds.length;
87
+ const someVisibleSelected = selectedVisible.length > 0 && !allVisibleSelected;
88
+ const toggleAllVisible = (checked) => {
89
+ if (checked) {
90
+ setSelection([.../* @__PURE__ */ new Set([...currentSelection, ...visibleIds])]);
91
+ } else {
92
+ setSelection(currentSelection.filter((id) => !visibleIds.includes(id)));
93
+ }
94
+ };
95
+ const toggleRow = (id, checked) => {
96
+ if (checked) setSelection([...currentSelection, id]);
97
+ else setSelection(currentSelection.filter((selected) => selected !== id));
98
+ };
99
+ const sortIcon = (key) => {
100
+ if (currentSort?.key !== key) return "swap_vert";
101
+ return currentSort.direction === "asc" ? "arrow_upward" : "arrow_downward";
102
+ };
103
+ const sortStateText = (key) => {
104
+ if (currentSort?.key !== key) return "not sorted";
105
+ return currentSort.direction === "asc" ? "sorted ascending" : "sorted descending";
106
+ };
107
+ const tableColumns = [
108
+ ...selectable ? [
109
+ {
110
+ key: "ds-data-table-select",
111
+ header: /* @__PURE__ */ jsx(
112
+ Checkbox,
113
+ {
114
+ size: "compact",
115
+ checked: allVisibleSelected,
116
+ indeterminate: someVisibleSelected,
117
+ onCheckedChange: toggleAllVisible,
118
+ "aria-label": "Select all rows on this page"
119
+ }
120
+ ),
121
+ width: "40px"
122
+ }
123
+ ] : [],
124
+ ...columns.map(
125
+ (col) => ({
126
+ key: col.key,
127
+ width: col.width,
128
+ align: col.align,
129
+ header: col.sortable ? /* @__PURE__ */ jsxs(
130
+ "button",
131
+ {
132
+ type: "button",
133
+ className: `${baseClass}__sort`,
134
+ "aria-label": `Sort by ${typeof col.header === "string" ? col.header : col.key}, ${sortStateText(col.key)}`,
135
+ onClick: () => cycleSort(col.key),
136
+ children: [
137
+ /* @__PURE__ */ jsx("span", { children: col.header }),
138
+ /* @__PURE__ */ jsx(
139
+ "span",
140
+ {
141
+ className: [
142
+ `${baseClass}__sort-icon`,
143
+ currentSort?.key === col.key ? `${baseClass}__sort-icon--active` : "",
144
+ "material-symbols-rounded"
145
+ ].filter(Boolean).join(" "),
146
+ "aria-hidden": "true",
147
+ children: sortIcon(col.key)
148
+ }
149
+ )
150
+ ]
151
+ }
152
+ ) : col.header
153
+ })
154
+ )
155
+ ];
156
+ const tableRows = visibleRows.map((row) => ({
157
+ id: row.id,
158
+ cells: {
159
+ ...selectable ? {
160
+ "ds-data-table-select": /* @__PURE__ */ jsx(
161
+ Checkbox,
162
+ {
163
+ size: "compact",
164
+ checked: currentSelection.includes(row.id),
165
+ onCheckedChange: (checked) => toggleRow(row.id, checked),
166
+ "aria-label": `Select row ${row.id}`
167
+ }
168
+ )
169
+ } : {},
170
+ ...Object.fromEntries(
171
+ columns.map((col) => [col.key, col.render ? col.render(row) : row.values[col.key]])
172
+ )
173
+ }
174
+ }));
175
+ const hasChrome = searchable || Boolean(toolbar);
176
+ const classes = [baseClass, className].filter(Boolean).join(" ");
177
+ return /* @__PURE__ */ jsxs("div", { ...rest, ref, className: classes, children: [
178
+ hasChrome && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__toolbar`, children: [
179
+ toolbar && /* @__PURE__ */ jsx("div", { className: `${baseClass}__filters`, children: toolbar }),
180
+ searchable && /* @__PURE__ */ jsx(
181
+ Input,
182
+ {
183
+ className: `${baseClass}__search`,
184
+ size: "compact",
185
+ iconLeft: "search",
186
+ placeholder: searchPlaceholder,
187
+ "aria-label": searchPlaceholder,
188
+ value: search,
189
+ onValueChange: (value) => {
190
+ setSearch(value);
191
+ setPage(1);
192
+ }
193
+ }
194
+ )
195
+ ] }),
196
+ visibleRows.length > 0 ? /* @__PURE__ */ jsx(
197
+ Table,
198
+ {
199
+ columns: tableColumns,
200
+ rows: tableRows,
201
+ size,
202
+ striped,
203
+ bordered: true,
204
+ caption,
205
+ captionHidden: true
206
+ }
207
+ ) : emptyState ?? /* @__PURE__ */ jsx(
208
+ EmptyState,
209
+ {
210
+ icon: "search_off",
211
+ title: "No matching rows",
212
+ description: "Adjust the search or filters and try again.",
213
+ variant: "bordered",
214
+ size: "compact"
215
+ }
216
+ ),
217
+ (pageSize !== void 0 || selectable) && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__footer`, children: [
218
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__count`, "aria-live": "polite", children: selectable && currentSelection.length > 0 ? `${currentSelection.length} selected` : `${sortedRows.length} result${sortedRows.length === 1 ? "" : "s"}` }),
219
+ pageSize !== void 0 && pageCount > 1 && /* @__PURE__ */ jsx(
220
+ Pagination,
221
+ {
222
+ page: clampedPage,
223
+ pageCount,
224
+ onPageChange: setPage,
225
+ size: "compact"
226
+ }
227
+ )
228
+ ] })
229
+ ] });
230
+ }
231
+ );
232
+ DataTable.displayName = "DataTable";
233
+ export {
234
+ DataTable
235
+ };
@@ -0,0 +1,328 @@
1
+ /* ============================================
2
+ EVENT CALENDAR COMPONENT
3
+ Month-at-a-glance grid with event pills.
4
+ Rows share the height of the fullest day in
5
+ their week (grid-auto-rows), so the calendar
6
+ grows with its content instead of fixing a
7
+ cell height.
8
+
9
+ Event accents use the core accent roles —
10
+ never the action teal, which stays reserved
11
+ for actions.
12
+ ============================================ */
13
+
14
+ .ds-event-calendar {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: var(--gap-sm);
18
+ width: 100%;
19
+ background-color: var(--color-bg-container-primary);
20
+ border: var(--border-xs) solid var(--color-bg-container-border);
21
+ border-radius: var(--radius-md);
22
+ padding: var(--padding-md);
23
+ }
24
+
25
+ /* ============================================
26
+ HEADER
27
+ ============================================ */
28
+
29
+ .ds-event-calendar__header {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: var(--gap-sm);
33
+ }
34
+
35
+ .ds-event-calendar__month {
36
+ flex: 1 1 auto;
37
+ min-width: 0;
38
+ color: var(--color-text-primary);
39
+ font-family: var(--font-title-body-family);
40
+ font-size: var(--font-title-body-size);
41
+ font-weight: var(--font-title-body-weight);
42
+ line-height: var(--font-title-body-line-height);
43
+ letter-spacing: var(--font-title-body-letter-spacing);
44
+ }
45
+
46
+ .ds-event-calendar__nav {
47
+ display: flex;
48
+ gap: var(--gap-xxs);
49
+ }
50
+
51
+ .ds-event-calendar__nav-btn {
52
+ --icon-size: var(--icon-size-sm);
53
+
54
+ display: inline-flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ padding: var(--padding-xxs);
58
+ background-color: var(--color-action-passive-bg);
59
+ border: none;
60
+ border-radius: var(--radius-full);
61
+ cursor: pointer;
62
+ color: var(--color-icon-primary);
63
+ transition: background-color var(--motion-duration-fast) var(--motion-ease-standard);
64
+ }
65
+
66
+ .ds-event-calendar__nav-btn:hover {
67
+ background-color: var(--color-action-passive-bg-hover);
68
+ }
69
+
70
+ .ds-event-calendar__nav-btn:focus-visible {
71
+ outline: 2px solid var(--color-action-primary-bg);
72
+ outline-offset: 2px;
73
+ }
74
+
75
+ .ds-event-calendar__actions {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: var(--gap-sm);
79
+ }
80
+
81
+ /* ============================================
82
+ WEEKDAY ROW
83
+ ============================================ */
84
+
85
+ .ds-event-calendar__weekdays {
86
+ display: grid;
87
+ grid-template-columns: repeat(7, 1fr);
88
+ gap: var(--gap-xxs);
89
+ }
90
+
91
+ .ds-event-calendar__weekday {
92
+ padding: var(--padding-xxs) var(--padding-xs);
93
+ color: var(--color-text-tertiary);
94
+ font-family: var(--font-paragraph-sm-em-family);
95
+ font-size: var(--font-paragraph-sm-em-size);
96
+ font-weight: var(--font-paragraph-sm-em-weight);
97
+ line-height: var(--font-paragraph-sm-em-line-height);
98
+ letter-spacing: var(--font-paragraph-sm-em-letter-spacing);
99
+ }
100
+
101
+ /* ============================================
102
+ GRID
103
+ ============================================ */
104
+
105
+ .ds-event-calendar__grid {
106
+ display: grid;
107
+ grid-template-columns: repeat(7, 1fr);
108
+ grid-auto-rows: 1fr;
109
+ gap: var(--gap-xxs);
110
+ }
111
+
112
+ .ds-event-calendar__cell {
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: var(--gap-xxs);
116
+ min-width: 0;
117
+ padding: var(--padding-xxs);
118
+ background-color: var(--color-bg-container-secondary);
119
+ border-radius: var(--radius-sm);
120
+ }
121
+
122
+ .ds-event-calendar__cell--outside {
123
+ background-color: var(--color-bg-container-primary-transparent);
124
+ }
125
+
126
+ .ds-event-calendar__cell--outside .ds-event-calendar__day {
127
+ color: var(--color-text-tertiary);
128
+ }
129
+
130
+ /* ============================================
131
+ DAY NUMBER
132
+ ============================================ */
133
+
134
+ .ds-event-calendar__day {
135
+ display: inline-flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ align-self: flex-start;
139
+ min-width: var(--icon-size-md);
140
+ height: var(--icon-size-md);
141
+ padding: 0 var(--padding-xxs);
142
+ background: none;
143
+ border: none;
144
+ border-radius: var(--radius-full);
145
+ color: var(--color-text-secondary);
146
+ font-family: var(--font-paragraph-sm-family);
147
+ font-size: var(--font-paragraph-sm-size);
148
+ font-weight: var(--font-paragraph-sm-weight);
149
+ line-height: var(--font-paragraph-sm-line-height);
150
+ letter-spacing: var(--font-paragraph-sm-letter-spacing);
151
+ font-variant-numeric: tabular-nums;
152
+ }
153
+
154
+ button.ds-event-calendar__day {
155
+ cursor: pointer;
156
+ transition: background-color var(--motion-duration-fast) var(--motion-ease-standard);
157
+ }
158
+
159
+ button.ds-event-calendar__day:hover {
160
+ background-color: var(--color-action-passive-bg-hover);
161
+ }
162
+
163
+ button.ds-event-calendar__day:focus-visible {
164
+ outline: 2px solid var(--color-action-primary-bg);
165
+ outline-offset: 2px;
166
+ }
167
+
168
+ /* Today — inverse chip on the day number, like DatePicker's today ring
169
+ but filled so it reads at calendar density */
170
+ .ds-event-calendar__cell--today .ds-event-calendar__day {
171
+ background-color: var(--color-bg-page-inverse);
172
+ color: var(--color-text-on-inverse);
173
+ font-family: var(--font-paragraph-sm-em-family);
174
+ font-weight: var(--font-paragraph-sm-em-weight);
175
+ letter-spacing: var(--font-paragraph-sm-em-letter-spacing);
176
+ }
177
+
178
+ /* ============================================
179
+ EVENT PILLS
180
+ ============================================ */
181
+
182
+ .ds-event-calendar__events {
183
+ display: flex;
184
+ flex-direction: column;
185
+ gap: var(--gap-xxs);
186
+ min-width: 0;
187
+ }
188
+
189
+ .ds-event-calendar__event {
190
+ --ds-event-accent: var(--color-status-neutral-border);
191
+
192
+ display: flex;
193
+ align-items: center;
194
+ gap: var(--gap-xxs);
195
+ min-width: 0;
196
+ padding: var(--padding-xxxs) var(--padding-xs);
197
+ background-color: var(--color-bg-container-primary);
198
+ border: none;
199
+ border-radius: var(--radius-xs);
200
+ text-align: left;
201
+ }
202
+
203
+ button.ds-event-calendar__event {
204
+ cursor: pointer;
205
+ transition: background-color var(--motion-duration-fast) var(--motion-ease-standard);
206
+ }
207
+
208
+ button.ds-event-calendar__event:hover {
209
+ background-color: var(--color-bg-container-tertiary);
210
+ }
211
+
212
+ button.ds-event-calendar__event:focus-visible {
213
+ outline: 2px solid var(--color-action-primary-bg);
214
+ outline-offset: -2px;
215
+ }
216
+
217
+ .ds-event-calendar__event-dot {
218
+ flex: none;
219
+ width: var(--gap-xs);
220
+ height: var(--gap-xs);
221
+ border-radius: var(--radius-full);
222
+ background-color: var(--ds-event-accent);
223
+ }
224
+
225
+ .ds-event-calendar__event-title {
226
+ flex: 1 1 auto;
227
+ min-width: 0;
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ white-space: nowrap;
231
+ color: var(--color-text-primary);
232
+ font-family: var(--font-paragraph-sm-em-family);
233
+ font-size: var(--font-paragraph-sm-em-size);
234
+ font-weight: var(--font-paragraph-sm-em-weight);
235
+ line-height: var(--font-paragraph-sm-em-line-height);
236
+ letter-spacing: var(--font-paragraph-sm-em-letter-spacing);
237
+ }
238
+
239
+ .ds-event-calendar__event-time {
240
+ flex: none;
241
+ color: var(--color-text-tertiary);
242
+ font-family: var(--font-paragraph-sm-family);
243
+ font-size: var(--font-paragraph-sm-size);
244
+ font-weight: var(--font-paragraph-sm-weight);
245
+ line-height: var(--font-paragraph-sm-line-height);
246
+ letter-spacing: var(--font-paragraph-sm-letter-spacing);
247
+ font-variant-numeric: tabular-nums;
248
+ }
249
+
250
+ /* ============================================
251
+ EVENT ACCENTS — core accent roles only
252
+ ============================================ */
253
+
254
+ .ds-event-calendar__event--coral {
255
+ --ds-event-accent: var(--color-core-accent-coral);
256
+ }
257
+
258
+ .ds-event-calendar__event--violet {
259
+ --ds-event-accent: var(--color-core-accent-violet);
260
+ }
261
+
262
+ .ds-event-calendar__event--cobalt {
263
+ --ds-event-accent: var(--color-core-accent-cobalt);
264
+ }
265
+
266
+ .ds-event-calendar__event--amber {
267
+ --ds-event-accent: var(--color-core-accent-amber);
268
+ }
269
+
270
+ .ds-event-calendar__event--gold {
271
+ --ds-event-accent: var(--color-core-accent-gold);
272
+ }
273
+
274
+ .ds-event-calendar__event--mint {
275
+ --ds-event-accent: var(--color-core-accent-mint);
276
+ }
277
+
278
+ /* ============================================
279
+ OVERFLOW
280
+ ============================================ */
281
+
282
+ .ds-event-calendar__more {
283
+ align-self: flex-start;
284
+ padding: var(--padding-xxxs) var(--padding-xs);
285
+ background: none;
286
+ border: none;
287
+ color: var(--color-text-tertiary);
288
+ font-family: var(--font-paragraph-sm-em-family);
289
+ font-size: var(--font-paragraph-sm-em-size);
290
+ font-weight: var(--font-paragraph-sm-em-weight);
291
+ line-height: var(--font-paragraph-sm-em-line-height);
292
+ letter-spacing: var(--font-paragraph-sm-em-letter-spacing);
293
+ }
294
+
295
+ button.ds-event-calendar__more {
296
+ cursor: pointer;
297
+ border-radius: var(--radius-xs);
298
+ transition:
299
+ background-color var(--motion-duration-fast) var(--motion-ease-standard),
300
+ color var(--motion-duration-fast) var(--motion-ease-standard);
301
+ }
302
+
303
+ button.ds-event-calendar__more:hover {
304
+ background-color: var(--color-bg-container-tertiary);
305
+ color: var(--color-text-secondary);
306
+ }
307
+
308
+ button.ds-event-calendar__more:focus-visible {
309
+ outline: 2px solid var(--color-action-primary-bg);
310
+ outline-offset: -2px;
311
+ }
312
+
313
+ /* ============================================
314
+ RESPONSIVE
315
+ At phone widths the pills drop their time so
316
+ titles keep at least a few characters.
317
+ ============================================ */
318
+
319
+ @media (max-width: 768px) {
320
+ .ds-event-calendar__event-time {
321
+ display: none;
322
+ }
323
+
324
+ .ds-event-calendar__weekday {
325
+ padding: var(--padding-xxs) 0;
326
+ text-align: center;
327
+ }
328
+ }
@@ -0,0 +1,51 @@
1
+ import { default as React } from 'react';
2
+ export type EventCalendarColor = 'neutral' | 'coral' | 'violet' | 'cobalt' | 'amber' | 'gold' | 'mint';
3
+ export interface EventCalendarEvent {
4
+ /** Unique event identifier. */
5
+ id: string;
6
+ /** The day the event falls on (YYYY-MM-DD). */
7
+ date: string;
8
+ /** Short label shown in the event pill. */
9
+ title: string;
10
+ /** Start time as already-formatted text like "09:30". Untimed events sort first. */
11
+ time?: string;
12
+ /** Accent for the pill's dot, from the core accent roles. Defaults to `neutral`. */
13
+ color?: EventCalendarColor;
14
+ }
15
+ /** Props owned by EventCalendar itself — everything else falls through to the root element. */
16
+ type EventCalendarOwnProps = {
17
+ /** The events to place on the grid, including any that fall on the visible leading and trailing days of the neighbouring months. */
18
+ events?: EventCalendarEvent[];
19
+ /** Shown month (YYYY-MM) for controlled use. Pair with `onMonthChange`. */
20
+ month?: string;
21
+ /** Initially shown month (YYYY-MM) for uncontrolled use. Defaults to the current month. */
22
+ defaultMonth?: string;
23
+ /** Fires with the newly shown month (YYYY-MM) after prev/next navigation. */
24
+ onMonthChange?: (month: string) => void;
25
+ /** Event pills shown per day before the rest collapse into "+N more". */
26
+ maxEventsPerDay?: number;
27
+ /** Fires with the clicked event. Pills only render as buttons when this is set. */
28
+ onEventClick?: (event: EventCalendarEvent) => void;
29
+ /**
30
+ * Fires with the clicked day (YYYY-MM-DD) — from the day number, and from
31
+ * the "+N more" overflow row. Both only become buttons when this is set.
32
+ */
33
+ onDateClick?: (date: string) => void;
34
+ /** Trailing header slot, e.g. a "New event" Button. */
35
+ actions?: React.ReactNode;
36
+ /** Additional CSS classes */
37
+ className?: string;
38
+ };
39
+ export interface EventCalendarProps extends EventCalendarOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof EventCalendarOwnProps> {
40
+ }
41
+ /**
42
+ * EventCalendar is the month-at-a-glance view: a full month grid with event
43
+ * pills on their days, an overflow row once a day is full, and prev/next
44
+ * month navigation. DatePicker selects a date; this one shows a schedule.
45
+ *
46
+ * The calendar owns no event data semantics — it places what it is given.
47
+ * Filtering, fetching, and what clicking an event means stay with the
48
+ * consumer.
49
+ */
50
+ export declare const EventCalendar: React.ForwardRefExoticComponent<EventCalendarProps & React.RefAttributes<HTMLDivElement>>;
51
+ export {};