@gtivr4/a1-design-system-react 0.4.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtivr4/a1-design-system-react",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "React components for the A1 token-driven design system.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -42,6 +42,12 @@ export function Calendar({
42
42
  dimPast = true,
43
43
  variant = "scroll",
44
44
  todayButton = false,
45
+ selectable = false,
46
+ selectedDate,
47
+ defaultSelectedDate,
48
+ onChange,
49
+ minDate,
50
+ maxDate,
45
51
  className = "",
46
52
  ...props
47
53
  }) {
@@ -63,6 +69,35 @@ export function Calendar({
63
69
  const [viewMonth, setViewMonth] = useState(centerMonth);
64
70
  const currentMonthRef = useRef(null);
65
71
 
72
+ // Selection — controlled when selectedDate is provided, otherwise internal state
73
+ const isControlled = selectedDate !== undefined;
74
+ const [internalSelected, setInternalSelected] = useState(defaultSelectedDate ?? null);
75
+ const selected = isControlled ? selectedDate : internalSelected;
76
+
77
+ function isSameDay(d, y, m, day) {
78
+ return d instanceof Date && d.getFullYear() === y && d.getMonth() === m && d.getDate() === day;
79
+ }
80
+
81
+ function isDayDisabled(y, m, day) {
82
+ const date = new Date(y, m, day);
83
+ if (minDate) {
84
+ const min = new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate());
85
+ if (date < min) return true;
86
+ }
87
+ if (maxDate) {
88
+ const max = new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate());
89
+ if (date > max) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function handleDayClick(y, m, day) {
95
+ if (isDayDisabled(y, m, day)) return;
96
+ const date = new Date(y, m, day);
97
+ if (!isControlled) setInternalSelected(date);
98
+ onChange?.(date);
99
+ }
100
+
66
101
  // ── Localised strings ─────────────────────────────────────────
67
102
 
68
103
  // Month names
@@ -210,15 +245,30 @@ export function Calendar({
210
245
  dimPast &&
211
246
  new Date(year, month, day) < new Date(todayYear, todayMonth, todayDay);
212
247
 
248
+ const isSelected = isSameDay(selected, year, month, day);
249
+ const isDisabled = isDayDisabled(year, month, day);
250
+
213
251
  return (
214
252
  <td
215
253
  key={di}
216
254
  className={[
217
255
  "a1-calendar__day",
218
- isToday && "a1-calendar__day--today",
219
- isPast && "a1-calendar__day--past",
256
+ isToday && "a1-calendar__day--today",
257
+ isPast && "a1-calendar__day--past",
258
+ isSelected && "a1-calendar__day--selected",
259
+ isDisabled && "a1-calendar__day--disabled",
220
260
  ].filter(Boolean).join(" ")}
221
261
  aria-current={isToday ? "date" : undefined}
262
+ aria-selected={isSelected ? true : undefined}
263
+ aria-disabled={isDisabled ? true : undefined}
264
+ tabIndex={selectable && !isDisabled ? 0 : undefined}
265
+ onClick={selectable && !isDisabled ? () => handleDayClick(year, month, day) : undefined}
266
+ onKeyDown={selectable && !isDisabled ? (e) => {
267
+ if (e.key === "Enter" || e.key === " ") {
268
+ e.preventDefault();
269
+ handleDayClick(year, month, day);
270
+ }
271
+ } : undefined}
222
272
  >
223
273
  <span className="a1-calendar__day-number" aria-hidden="true">
224
274
  {day}
@@ -235,17 +285,27 @@ export function Calendar({
235
285
 
236
286
  // ── Paginated variant ─────────────────────────────────────────
237
287
  if (variant === "paginated") {
238
- const yearStart = todayYear - 10;
239
- const yearEnd = todayYear + 15;
288
+ const minYear = minDate ? minDate.getFullYear() : todayYear - 10;
289
+ const minMon = minDate ? minDate.getMonth() : 0;
290
+ const maxYear = maxDate ? maxDate.getFullYear() : todayYear + 15;
291
+ const maxMon = maxDate ? maxDate.getMonth() : 11;
292
+
293
+ const yearStart = minDate ? minDate.getFullYear() : todayYear - 10;
294
+ const yearEnd = maxDate ? maxDate.getFullYear() : todayYear + 15;
240
295
  const years = Array.from({ length: yearEnd - yearStart + 1 }, (_, i) => yearStart + i);
241
296
 
297
+ const prevAtMin = viewYear === minYear && viewMonth === minMon;
298
+ const nextAtMax = viewYear === maxYear && viewMonth === maxMon;
299
+
242
300
  const goPrev = () => {
301
+ if (prevAtMin) return;
243
302
  const p = addMonths(viewYear, viewMonth, -1);
244
303
  setViewYear(p.year);
245
304
  setViewMonth(p.month);
246
305
  };
247
306
 
248
307
  const goNext = () => {
308
+ if (nextAtMax) return;
249
309
  const n = addMonths(viewYear, viewMonth, 1);
250
310
  setViewYear(n.year);
251
311
  setViewMonth(n.month);
@@ -263,19 +323,19 @@ export function Calendar({
263
323
 
264
324
  return (
265
325
  <div
266
- className={["a1-calendar", "a1-calendar--paginated", className].filter(Boolean).join(" ")}
326
+ className={["a1-calendar", "a1-calendar--paginated", selectable && "a1-calendar--selectable", className].filter(Boolean).join(" ")}
267
327
  {...props}
268
328
  >
269
329
  <div className="a1-calendar__inner">
270
330
  <div className="a1-calendar__nav">
271
331
 
272
332
  <span className="a1-calendar__nav-wide">
273
- <Button variant="tertiary" size="sm" icon={isRtl ? "chevron_right" : "chevron_left"} iconPosition="start" onClick={goPrev}>
333
+ <Button variant="tertiary" size="sm" icon={isRtl ? "chevron_right" : "chevron_left"} iconPosition="start" onClick={goPrev} disabled={prevAtMin}>
274
334
  {prevMonthLabel}
275
335
  </Button>
276
336
  </span>
277
337
  <span className="a1-calendar__nav-narrow">
278
- <IconButton icon={isRtl ? "chevron_right" : "chevron_left"} label={prevMonthLabel} variant="tertiary" onClick={goPrev} />
338
+ <IconButton icon={isRtl ? "chevron_right" : "chevron_left"} label={prevMonthLabel} variant="tertiary" onClick={goPrev} disabled={prevAtMin} />
279
339
  </span>
280
340
 
281
341
  <div className="a1-calendar__nav-label">
@@ -286,9 +346,12 @@ export function Calendar({
286
346
  value={viewMonth}
287
347
  onChange={e => setViewMonth(Number(e.target.value))}
288
348
  >
289
- {MONTHS.map((name, i) => (
290
- <option key={i} value={i}>{name}</option>
291
- ))}
349
+ {MONTHS.map((name, i) => {
350
+ const disabled =
351
+ (viewYear === minYear && i < minMon) ||
352
+ (viewYear === maxYear && i > maxMon);
353
+ return <option key={i} value={i} disabled={disabled}>{name}</option>;
354
+ })}
292
355
  </SelectField>
293
356
  <SelectField
294
357
  className="a1-calendar__nav-field"
@@ -327,12 +390,12 @@ export function Calendar({
327
390
  </div>
328
391
 
329
392
  <span className="a1-calendar__nav-wide">
330
- <Button variant="tertiary" size="sm" icon={isRtl ? "chevron_left" : "chevron_right"} iconPosition="end" onClick={goNext}>
393
+ <Button variant="tertiary" size="sm" icon={isRtl ? "chevron_left" : "chevron_right"} iconPosition="end" onClick={goNext} disabled={nextAtMax}>
331
394
  {nextMonthLabel}
332
395
  </Button>
333
396
  </span>
334
397
  <span className="a1-calendar__nav-narrow">
335
- <IconButton icon={isRtl ? "chevron_left" : "chevron_right"} label={nextMonthLabel} variant="tertiary" onClick={goNext} />
398
+ <IconButton icon={isRtl ? "chevron_left" : "chevron_right"} label={nextMonthLabel} variant="tertiary" onClick={goNext} disabled={nextAtMax} />
336
399
  </span>
337
400
 
338
401
  </div>
@@ -351,7 +414,7 @@ export function Calendar({
351
414
 
352
415
  return (
353
416
  <div
354
- className={["a1-calendar", className].filter(Boolean).join(" ")}
417
+ className={["a1-calendar", selectable && "a1-calendar--selectable", className].filter(Boolean).join(" ")}
355
418
  {...props}
356
419
  >
357
420
  <div className="a1-calendar__inner">
@@ -110,6 +110,50 @@
110
110
  background: var(--semantic-color-surface-raised);
111
111
  }
112
112
 
113
+ /* ── Selected — full-cell fill (display and selectable modes) ─── */
114
+
115
+ .a1-calendar__day--selected {
116
+ background: var(--semantic-color-action-background);
117
+ color: var(--semantic-color-text-inverse);
118
+ }
119
+
120
+ /* ── Disabled (out of range) — same surface as past dates ──────── */
121
+
122
+ .a1-calendar__day--disabled {
123
+ background: var(--semantic-color-surface-raised);
124
+ }
125
+
126
+ /* ── Selectable mode (opt-in via selectable prop) ──────────────── */
127
+
128
+ .a1-calendar--selectable .a1-calendar__day:not(.a1-calendar__day--empty):not(.a1-calendar__day--disabled) {
129
+ cursor: pointer;
130
+ }
131
+ .a1-calendar--selectable .a1-calendar__day--disabled {
132
+ cursor: not-allowed;
133
+ }
134
+
135
+ /* Today — neutral ring instead of action fill (yields to selected) */
136
+ .a1-calendar--selectable .a1-calendar__day--today:not(.a1-calendar__day--selected) {
137
+ background: transparent;
138
+ color: var(--semantic-color-text-default);
139
+ }
140
+ .a1-calendar--selectable .a1-calendar__day--today:not(.a1-calendar__day--selected) .a1-calendar__day-number {
141
+ outline: 2px solid var(--semantic-color-text-default);
142
+ outline-offset: -1px;
143
+ }
144
+
145
+ /* Full-cell hover for non-selected, non-disabled days */
146
+ .a1-calendar--selectable .a1-calendar__day:not(.a1-calendar__day--empty):not(.a1-calendar__day--disabled):not(.a1-calendar__day--selected):hover {
147
+ background: var(--semantic-color-surface-panel);
148
+ color: var(--semantic-color-text-default);
149
+ }
150
+
151
+ /* Focus ring */
152
+ .a1-calendar--selectable .a1-calendar__day:focus-visible {
153
+ outline: var(--component-field-focus-ring-width) solid var(--component-field-focus-ring-color);
154
+ outline-offset: -2px;
155
+ }
156
+
113
157
  /* ── Paginated variant — nav bar ─────────────────────────── */
114
158
 
115
159
  .a1-calendar__nav {
@@ -178,6 +222,33 @@
178
222
  color: var(--semantic-color-text-inverse);
179
223
  }
180
224
 
225
+ /* Selected: revert to circle in narrow view */
226
+ .a1-calendar__day--selected {
227
+ background: transparent;
228
+ color: var(--semantic-color-text-default);
229
+ }
230
+ .a1-calendar__day--selected .a1-calendar__day-number {
231
+ background: var(--semantic-color-action-background);
232
+ color: var(--semantic-color-text-inverse);
233
+ }
234
+
235
+ /* Selectable: today ring (suppress action-bg circle from non-selectable narrow rule) */
236
+ .a1-calendar--selectable .a1-calendar__day--today:not(.a1-calendar__day--selected) .a1-calendar__day-number {
237
+ background: transparent;
238
+ color: var(--semantic-color-text-default);
239
+ outline: 2px solid var(--semantic-color-text-default);
240
+ outline-offset: -1px;
241
+ }
242
+
243
+ /* Selectable: hover as circle in narrow */
244
+ .a1-calendar--selectable .a1-calendar__day:not(.a1-calendar__day--empty):not(.a1-calendar__day--disabled):not(.a1-calendar__day--selected):hover {
245
+ background: transparent;
246
+ color: var(--semantic-color-text-default);
247
+ }
248
+ .a1-calendar--selectable .a1-calendar__day:not(.a1-calendar__day--empty):not(.a1-calendar__day--disabled):not(.a1-calendar__day--selected):hover .a1-calendar__day-number {
249
+ background: var(--semantic-color-surface-panel);
250
+ }
251
+
181
252
  .a1-calendar__nav {
182
253
  padding-block: var(--component-calendar-heading-padding-block-compact);
183
254
  }