atlas-react-kit 1.0.0 → 1.1.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 (37) hide show
  1. package/COMPONENTS.md +41 -37
  2. package/DESIGN_RULES.md +23 -32
  3. package/README.md +5 -47
  4. package/dist/date-picker.d.ts +13 -2
  5. package/dist/date-picker.js +35 -22
  6. package/dist/date-time-picker.d.ts +1 -1
  7. package/dist/date-time-picker.js +24 -38
  8. package/dist/form/date-picker-field.d.ts +6 -1
  9. package/dist/form/date-picker-field.js +3 -2
  10. package/dist/form/date-time-picker-field.d.ts +1 -1
  11. package/dist/form/date-time-picker-field.js +3 -2
  12. package/dist/form/field-error.d.ts +4 -3
  13. package/dist/form/field-shell.d.ts +6 -5
  14. package/dist/form/field-shell.js +1 -1
  15. package/dist/form/focus-first-invalid.d.ts +28 -0
  16. package/dist/form/focus-first-invalid.js +29 -0
  17. package/dist/form/select-field.d.ts +7 -1
  18. package/dist/form/select-field.js +3 -2
  19. package/dist/internal/listbox-navigation.d.ts +0 -11
  20. package/dist/internal/listbox-navigation.js +2 -2
  21. package/dist/internal/listbox-option.d.ts +0 -15
  22. package/dist/internal/listbox-option.js +1 -1
  23. package/dist/internal/use-drag-scroll.d.ts +17 -0
  24. package/dist/internal/use-drag-scroll.js +98 -0
  25. package/dist/nav-link.d.ts +12 -4
  26. package/dist/nav-link.js +14 -4
  27. package/dist/next/nav-link.d.ts +6 -3
  28. package/dist/next/nav-link.js +8 -6
  29. package/dist/next/tabs-nav-link.d.ts +6 -0
  30. package/dist/next/tabs-nav-link.js +8 -3
  31. package/dist/picker-shell.d.ts +24 -21
  32. package/dist/picker-shell.js +12 -10
  33. package/dist/tabs.d.ts +18 -7
  34. package/dist/tabs.js +22 -9
  35. package/package.json +7 -3
  36. package/skills/atlas-react-kit/SKILL.md +46 -40
  37. package/theme.css +2 -0
@@ -2,8 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { format } from "date-fns";
5
- import { LazyCalendar } from "./lazy-calendar.js";
6
- import { PickerClearFooter, PickerShell, limitCalendarProps, } from "./picker-shell.js";
5
+ import { PickerShell } from "./picker-shell.js";
7
6
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./select.js";
8
7
  /** Default clock increment, in minutes. Must divide 60. */
9
8
  export const DEFAULT_TIME_STEP = 15;
@@ -48,18 +47,6 @@ function minuteOptions(step, current) {
48
47
  return minutes;
49
48
  }
50
49
  const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => padTimePart(hour));
51
- /**
52
- * Keep the date-time popover open when a portaled Select option is clicked.
53
- * Select content renders outside the popover, so Radix would otherwise treat
54
- * that pointer down as an outside dismiss.
55
- */
56
- function preventDismissForPortaledSelect(event) {
57
- const target = event.target;
58
- if (target instanceof Element &&
59
- target.closest("[data-slot=select-content]")) {
60
- event.preventDefault();
61
- }
62
- }
63
50
  /**
64
51
  * Calendar plus hour/minute popover trigger styled as a form control (same
65
52
  * chrome as Input, SelectTrigger, and DatePickerButton). `value` is a `Date`
@@ -68,33 +55,32 @@ function preventDismissForPortaledSelect(event) {
68
55
  * stays on-screen at a 390px width. `className` styles the trigger,
69
56
  * `contentClassName` the popover, and `calendarProps` reaches Day Picker.
70
57
  */
71
- export function DateTimePickerButton({ value, onValueChange, onChange, timeStep, placeholder = "Pick a date and time", defaultMonth, calendarProps, minDate, maxDate, ...shell }) {
58
+ export function DateTimePickerButton({ value, onValueChange, onChange, timeStep, placeholder = "Pick a date and time", defaultMonth, ...shell }) {
72
59
  const [open, setOpen] = React.useState(false);
73
60
  const emit = onValueChange ?? onChange;
74
61
  const date = value ?? null;
75
62
  const step = resolveTimeStep(timeStep);
76
63
  const empty = date == null;
77
- const hourId = React.useId();
78
- const minuteId = React.useId();
79
- return (_jsxs(PickerShell, { ...shell, empty: empty, label: date ? format(date, "PPP p") : placeholder, dialogLabel: "Choose date and time", open: open, onOpenChange: setOpen, onInteractOutside: preventDismissForPortaledSelect, children: [_jsx(LazyCalendar, { ...limitCalendarProps(calendarProps, minDate, maxDate), mode: "single", autoFocus: true, selected: date ?? undefined, defaultMonth: defaultMonth ?? date ?? undefined, onSelect: (next) => {
80
- // Re-clicking a selected day would clear it; require Clear instead
81
- // so a time already set is not dropped by an accidental second tap.
82
- if (!next)
83
- return;
84
- emit?.(combineDateAndTime(next, date?.getHours() ?? 0, date?.getMinutes() ?? 0));
85
- } }), _jsxs("div", { role: "group", "aria-label": "Time", "data-slot": "date-time-picker-time", className: "flex min-w-0 flex-col gap-2 border-t p-3", children: [_jsx("p", { className: "text-xs font-semibold tracking-wide uppercase", children: "Time" }), _jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsxs(Select, { value: date ? padTimePart(date.getHours()) : undefined, onValueChange: (nextHour) => {
86
- if (!date)
87
- return;
88
- emit?.(combineDateAndTime(date, Number(nextHour), date.getMinutes()));
89
- }, disabled: empty, children: [_jsx(SelectTrigger, { id: hourId, "aria-label": "Hour", className: "min-w-0 flex-1", children: _jsx(SelectValue, { placeholder: "\u2013\u2013" }) }), _jsx(SelectContent, { children: HOUR_OPTIONS.map((hour) => (_jsx(SelectItem, { value: hour, children: hour }, hour))) })] }), _jsx("span", { className: "text-muted-foreground", "aria-hidden": "true", children: ":" }), _jsxs(Select, { value: date ? padTimePart(date.getMinutes()) : undefined, onValueChange: (nextMinute) => {
90
- if (!date)
91
- return;
92
- emit?.(combineDateAndTime(date, date.getHours(), Number(nextMinute)));
93
- }, disabled: empty, children: [_jsx(SelectTrigger, { id: minuteId, "aria-label": "Minute", className: "min-w-0 flex-1", children: _jsx(SelectValue, { placeholder: "\u2013\u2013" }) }), _jsx(SelectContent, { children: minuteOptions(step, date?.getMinutes()).map((minute) => {
94
- const value = padTimePart(minute);
95
- return (_jsx(SelectItem, { value: value, children: value }, value));
96
- }) })] })] })] }), _jsx(PickerClearFooter, { disabled: empty, onClear: () => {
97
- emit?.(null);
98
- setOpen(false);
99
- } })] }));
64
+ return (_jsx(PickerShell, { ...shell, empty: empty, label: date ? format(date, "PPP p") : placeholder, dialogLabel: "Choose date and time", open: open, onOpenChange: setOpen, defaultMonth: defaultMonth ?? date ?? undefined, calendar: {
65
+ mode: "single",
66
+ selected: date ?? undefined,
67
+ onSelect: (next) => {
68
+ // Re-clicking a selected day would clear it; require Clear instead
69
+ // so a time already set is not dropped by an accidental second tap.
70
+ if (!next)
71
+ return;
72
+ emit?.(combineDateAndTime(next, date?.getHours() ?? 0, date?.getMinutes() ?? 0));
73
+ },
74
+ }, onClear: () => emit?.(null), clearDisabled: empty, children: _jsxs("div", { role: "group", "aria-label": "Time", "data-slot": "date-time-picker-time", className: "flex min-w-0 flex-col gap-2 border-t p-3", children: [_jsx("p", { className: "text-xs font-semibold tracking-wide uppercase", children: "Time" }), _jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsxs(Select, { value: date ? padTimePart(date.getHours()) : undefined, onValueChange: (nextHour) => {
75
+ if (!date)
76
+ return;
77
+ emit?.(combineDateAndTime(date, Number(nextHour), date.getMinutes()));
78
+ }, disabled: empty, children: [_jsx(SelectTrigger, { "aria-label": "Hour", className: "min-w-0 flex-1", children: _jsx(SelectValue, { placeholder: "\u2013\u2013" }) }), _jsx(SelectContent, { children: HOUR_OPTIONS.map((hour) => (_jsx(SelectItem, { value: hour, children: hour }, hour))) })] }), _jsx("span", { className: "text-muted-foreground", "aria-hidden": "true", children: ":" }), _jsxs(Select, { value: date ? padTimePart(date.getMinutes()) : undefined, onValueChange: (nextMinute) => {
79
+ if (!date)
80
+ return;
81
+ emit?.(combineDateAndTime(date, date.getHours(), Number(nextMinute)));
82
+ }, disabled: empty, children: [_jsx(SelectTrigger, { "aria-label": "Minute", className: "min-w-0 flex-1", children: _jsx(SelectValue, { placeholder: "\u2013\u2013" }) }), _jsx(SelectContent, { children: minuteOptions(step, date?.getMinutes()).map((minute) => {
83
+ const value = padTimePart(minute);
84
+ return (_jsx(SelectItem, { value: value, children: value }, value));
85
+ }) })] })] })] }) }));
100
86
  }
@@ -7,7 +7,12 @@ type Props = PickerFieldBaseProps & {
7
7
  onValueChange: (date: Date | undefined) => void;
8
8
  /** Trigger text while no day is picked. Default "Pick a date". */
9
9
  placeholder?: string;
10
+ /**
11
+ * Shows a Clear row in the popover, which calls `onValueChange` with
12
+ * `undefined`. Default `false`.
13
+ */
14
+ clearable?: boolean;
10
15
  };
11
16
  /** Date picker with built-in field error text and invalid styling. */
12
- export declare function FormDatePickerField({ onValueChange, label, meta, submitted, showError, required, fieldClassName, ...picker }: Props): import("react").JSX.Element;
17
+ export declare function FormDatePickerField({ id: idProp, onValueChange, label, meta, submitted, showError, required, fieldClassName, ...picker }: Props): import("react").JSX.Element;
13
18
  export {};
@@ -4,8 +4,9 @@ import { useId } from "react";
4
4
  import { DatePickerButton } from "../date-picker.js";
5
5
  import { FormFieldShell, metaErrorText, pickerFieldAria, } from "./field-shell.js";
6
6
  /** Date picker with built-in field error text and invalid styling. */
7
- export function FormDatePickerField({ onValueChange, label, meta, submitted, showError, required, fieldClassName, ...picker }) {
7
+ export function FormDatePickerField({ id: idProp, onValueChange, label, meta, submitted, showError, required, fieldClassName, ...picker }) {
8
8
  const errorText = metaErrorText(meta, { submitted, showError });
9
- const id = useId();
9
+ const generatedId = useId();
10
+ const id = idProp ?? generatedId;
10
11
  return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, required: required, className: fieldClassName, children: _jsx(DatePickerButton, { ...picker, id: id, onValueChange: onValueChange, ...pickerFieldAria(id, errorText, required) }) }));
11
12
  }
@@ -10,5 +10,5 @@ type Props = PickerFieldBaseProps & {
10
10
  timeStep?: number;
11
11
  };
12
12
  /** Date-time picker with built-in field error text and invalid styling. */
13
- export declare function FormDateTimePickerField({ onValueChange, label, meta, submitted, showError, required, fieldClassName, ...picker }: Props): import("react").JSX.Element;
13
+ export declare function FormDateTimePickerField({ id: idProp, onValueChange, label, meta, submitted, showError, required, fieldClassName, ...picker }: Props): import("react").JSX.Element;
14
14
  export {};
@@ -4,8 +4,9 @@ import { useId } from "react";
4
4
  import { DateTimePickerButton } from "../date-time-picker.js";
5
5
  import { FormFieldShell, metaErrorText, pickerFieldAria, } from "./field-shell.js";
6
6
  /** Date-time picker with built-in field error text and invalid styling. */
7
- export function FormDateTimePickerField({ onValueChange, label, meta, submitted, showError, required, fieldClassName, ...picker }) {
7
+ export function FormDateTimePickerField({ id: idProp, onValueChange, label, meta, submitted, showError, required, fieldClassName, ...picker }) {
8
8
  const errorText = metaErrorText(meta, { submitted, showError });
9
- const id = useId();
9
+ const generatedId = useId();
10
+ const id = idProp ?? generatedId;
10
11
  return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, required: required, className: fieldClassName, children: _jsx(DateTimePickerButton, { ...picker, id: id, onValueChange: onValueChange, ...pickerFieldAria(id, errorText, required) }) }));
11
12
  }
@@ -16,9 +16,10 @@ export type FieldMetaState = {
16
16
  /** When a field shows its error: the `form/*` fields' shared timing props. */
17
17
  export type ErrorTimingProps = {
18
18
  /**
19
- * Set once the form has been submitted (TanStack Form:
20
- * `form.state.submissionAttempts > 0`), so an untouched field shows its
21
- * error too. Only matters when `meta.isTouched` is passed.
19
+ * Set once the form has been submitted, so an untouched field shows its
20
+ * error too. For form libraries that do not mark fields touched on
21
+ * submit; TanStack Form does, so its users pass `meta` only. Only matters
22
+ * when `meta.isTouched` is passed.
22
23
  */
23
24
  submitted?: boolean;
24
25
  /** When false, never shows error text or invalid styling. Default true. */
@@ -20,11 +20,6 @@ export declare function labelIdFor(id: string): string;
20
20
  export declare function descriptionIdFor(id: string): string;
21
21
  /** Id of the error text `FormFieldShell` renders for control `id`. */
22
22
  export declare function errorIdFor(id: string): string;
23
- /**
24
- * Id of the hidden "Required" text `FormFieldShell` renders for a required
25
- * control `id`, for controls that cannot carry `required` themselves.
26
- */
27
- export declare function requiredIdFor(id: string): string;
28
23
  /**
29
24
  * `aria-describedby` for the control: the given hint ids, plus the error
30
25
  * text while there is one, or `undefined` when there is nothing to read.
@@ -35,6 +30,12 @@ export declare function describedBy(id: string, errorText: string | undefined, .
35
30
  * `onValueChange`, and `placeholder`.
36
31
  */
37
32
  export type PickerFieldBaseProps = FormFieldBaseProps & FormFieldMetaProps & {
33
+ /**
34
+ * Id of the trigger the label points at. Defaults to a generated id;
35
+ * pass the field name (`id={field.name}`) so a submit handler such as
36
+ * `focusFirstInvalid` can find the control.
37
+ */
38
+ id?: string;
38
39
  /** Disables the trigger so no date can be picked. */
39
40
  disabled?: boolean;
40
41
  /** Marks the label and adds "Required" to the trigger's description. */
@@ -40,7 +40,7 @@ export function errorIdFor(id) {
40
40
  * Id of the hidden "Required" text `FormFieldShell` renders for a required
41
41
  * control `id`, for controls that cannot carry `required` themselves.
42
42
  */
43
- export function requiredIdFor(id) {
43
+ function requiredIdFor(id) {
44
44
  return `${id}-required`;
45
45
  }
46
46
  /**
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The part of a form library's invalid-submit event this helper reads: the
3
+ * form state's per-field meta, keyed by field name (TanStack Form's
4
+ * `onSubmitInvalid` passes `{ formApi }` with this shape).
5
+ */
6
+ export type InvalidSubmitEvent = {
7
+ formApi: {
8
+ state: {
9
+ fieldMeta: Partial<Record<string, {
10
+ errors: readonly unknown[];
11
+ }>>;
12
+ };
13
+ };
14
+ };
15
+ /**
16
+ * Invalid-submit handler that focuses the first field, in page order, whose
17
+ * meta has errors. It reads the form state, not the rendered `aria-invalid`,
18
+ * so it works before React has re-rendered. Fields are matched by control
19
+ * id, so give each `form/*` field `id={field.name}` (`FormTextField`
20
+ * defaults to it). The form library needs no focus API of its own.
21
+ *
22
+ * ```tsx
23
+ * const form = useForm({ onSubmitInvalid: focusFirstInvalid("order-form") })
24
+ * <form id="order-form" onSubmit={...}>
25
+ * <FormSelectField id={field.name} ... />
26
+ * ```
27
+ */
28
+ export declare function focusFirstInvalid(formId: string): ({ formApi }: InvalidSubmitEvent) => void;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Invalid-submit handler that focuses the first field, in page order, whose
3
+ * meta has errors. It reads the form state, not the rendered `aria-invalid`,
4
+ * so it works before React has re-rendered. Fields are matched by control
5
+ * id, so give each `form/*` field `id={field.name}` (`FormTextField`
6
+ * defaults to it). The form library needs no focus API of its own.
7
+ *
8
+ * ```tsx
9
+ * const form = useForm({ onSubmitInvalid: focusFirstInvalid("order-form") })
10
+ * <form id="order-form" onSubmit={...}>
11
+ * <FormSelectField id={field.name} ... />
12
+ * ```
13
+ */
14
+ export function focusFirstInvalid(formId) {
15
+ return ({ formApi }) => {
16
+ const form = document.getElementById(formId);
17
+ if (!(form instanceof HTMLFormElement))
18
+ return;
19
+ const invalid = new Set(Object.entries(formApi.state.fieldMeta)
20
+ .filter(([, meta]) => meta?.errors.length)
21
+ .map(([name]) => name));
22
+ for (const element of form.elements) {
23
+ if (element instanceof HTMLElement && invalid.has(element.id)) {
24
+ element.focus();
25
+ return;
26
+ }
27
+ }
28
+ };
29
+ }
@@ -7,6 +7,12 @@ type Props = FormFieldBaseProps & FormFieldMetaProps & {
7
7
  onValueChange: (value: string) => void;
8
8
  /** Trigger text while no item is selected. */
9
9
  placeholder?: string;
10
+ /**
11
+ * Id of the trigger the label points at. Defaults to a generated id;
12
+ * pass the field name (`id={field.name}`) so a submit handler such as
13
+ * `focusFirstInvalid` can find the control.
14
+ */
15
+ id?: string;
10
16
  /** Requires a value (native form validation) and marks the label. */
11
17
  required?: boolean;
12
18
  /** Forwarded to the select trigger. */
@@ -17,5 +23,5 @@ type Props = FormFieldBaseProps & FormFieldMetaProps & {
17
23
  children: ReactNode;
18
24
  };
19
25
  /** Select control with built-in field error text and invalid styling. */
20
- export declare function FormSelectField({ value, meta, submitted, showError, label, onValueChange, placeholder, required, className, fieldClassName, contentClassName, children, }: Props): import("react").JSX.Element;
26
+ export declare function FormSelectField({ id: idProp, value, meta, submitted, showError, label, onValueChange, placeholder, required, className, fieldClassName, contentClassName, children, }: Props): import("react").JSX.Element;
21
27
  export {};
@@ -4,8 +4,9 @@ import { useId } from "react";
4
4
  import { Select, SelectContent, SelectTrigger, SelectValue } from "../select.js";
5
5
  import { describedBy, FormFieldShell, invalidWhen, metaErrorText, } from "./field-shell.js";
6
6
  /** Select control with built-in field error text and invalid styling. */
7
- export function FormSelectField({ value, meta, submitted, showError, label, onValueChange, placeholder, required, className, fieldClassName, contentClassName, children, }) {
7
+ export function FormSelectField({ id: idProp, value, meta, submitted, showError, label, onValueChange, placeholder, required, className, fieldClassName, contentClassName, children, }) {
8
8
  const errorText = metaErrorText(meta, { submitted, showError });
9
- const id = useId();
9
+ const generatedId = useId();
10
+ const id = idProp ?? generatedId;
10
11
  return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, required: required, className: fieldClassName, children: _jsxs(Select, { value: value, onValueChange: onValueChange, required: required, children: [_jsx(SelectTrigger, { id: id, className: className, "aria-required": required || undefined, "aria-invalid": invalidWhen(errorText), "aria-describedby": describedBy(id, errorText), children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsx(SelectContent, { className: contentClassName, children: children })] }) }));
11
12
  }
@@ -1,5 +1,3 @@
1
- /** Return the next highlighted index for a listbox navigation key. */
2
- export declare function moveListboxHighlight(key: string, index: number, length: number): number | undefined;
3
1
  /**
4
2
  * Option id for `aria-activedescendant`. Index-based because option values
5
3
  * may contain spaces, which are not valid in an id reference.
@@ -12,15 +10,6 @@ export declare function listboxOptionId(listId: string, index: number): string;
12
10
  * so the ref is stable without `useCallback`.
13
11
  */
14
12
  export declare function scrollOptionIntoView(node: HTMLElement | null): void;
15
- /**
16
- * True while an IME is composing (Japanese, Chinese, Korean input). Enter
17
- * then commits the composition and must not select an option. `keyCode`
18
- * 229 covers Safari, which ends composition before the keydown.
19
- */
20
- export declare function isComposingKey(event: {
21
- nativeEvent: KeyboardEvent;
22
- keyCode: number;
23
- }): boolean;
24
13
  /**
25
14
  * Shared keydown for a search input that drives a listbox: navigation keys
26
15
  * move the highlight, Enter picks the highlighted index. Returns `true` when
@@ -1,7 +1,7 @@
1
1
  /** Page keys move ten rows, a little more than one max-h-60 list viewport. */
2
2
  const PAGE_SIZE = 10;
3
3
  /** Return the next highlighted index for a listbox navigation key. */
4
- export function moveListboxHighlight(key, index, length) {
4
+ function moveListboxHighlight(key, index, length) {
5
5
  switch (key) {
6
6
  case "ArrowDown":
7
7
  return length === 0 ? 0 : Math.min(index + 1, length - 1);
@@ -40,7 +40,7 @@ export function scrollOptionIntoView(node) {
40
40
  * then commits the composition and must not select an option. `keyCode`
41
41
  * 229 covers Safari, which ends composition before the keydown.
42
42
  */
43
- export function isComposingKey(event) {
43
+ function isComposingKey(event) {
44
44
  return event.nativeEvent.isComposing || event.keyCode === 229;
45
45
  }
46
46
  /**
@@ -1,19 +1,4 @@
1
1
  import * as React from "react";
2
- type ListboxOptionProps = Omit<React.ComponentProps<"button">, "id" | "role" | "type" | "tabIndex" | "aria-selected"> & {
3
- /** The listbox id; the option id derives from it for aria-activedescendant. */
4
- listId: string;
5
- index: number;
6
- /** Keyboard or pointer highlight (tracked in state, not DOM focus). */
7
- active: boolean;
8
- selected: boolean;
9
- };
10
- /**
11
- * One option of a search-driven listbox (Combobox, SearchPicker): a
12
- * non-focusable button in a presentational `li`, highlighted by state and
13
- * scrolled into view while active. Pass `data-slot`, handlers, and the
14
- * component's right padding and indicator.
15
- */
16
- export declare function ListboxOption({ listId, index, active, selected, className, ...props }: ListboxOptionProps): React.JSX.Element;
17
2
  type PickerOptionProps<T> = {
18
3
  item: T;
19
4
  getLabel: (item: T) => string;
@@ -11,7 +11,7 @@ import { listboxEmpty, optionState } from "./styles.js";
11
11
  * scrolled into view while active. Pass `data-slot`, handlers, and the
12
12
  * component's right padding and indicator.
13
13
  */
14
- export function ListboxOption({ listId, index, active, selected, className, ...props }) {
14
+ function ListboxOption({ listId, index, active, selected, className, ...props }) {
15
15
  return (_jsx("li", { role: "none", children: _jsx("button", { ref: active ? scrollOptionIntoView : undefined, id: listboxOptionId(listId, index), type: "button", role: "option", tabIndex: -1, "aria-selected": selected, className: cn(optionRow, selected && optionState.selected, active && optionState.active, className), ...props }) }));
16
16
  }
17
17
  /** Only the old and new active rows rerender during keyboard navigation. */
@@ -0,0 +1,17 @@
1
+ import * as React from "react";
2
+ /** Pointer travel, in px, before a press on the scroller counts as a drag. */
3
+ export declare const DRAG_THRESHOLD = 4;
4
+ /**
5
+ * Lets a mouse drag a horizontal scroller sideways, the way touch already
6
+ * does natively. Mouse only (`pointerType === "mouse"`, left button); touch
7
+ * and pen keep the browser's own scrolling. A press becomes a drag after
8
+ * `DRAG_THRESHOLD` px of travel, so a plain click on a tab or link still
9
+ * activates it; once a drag has moved, the click after pointerup is
10
+ * swallowed so the drag never also follows a link. (Radix `TabsTrigger`
11
+ * selects on mousedown, so a drag that starts on an inactive tab still
12
+ * selects that tab on press.)
13
+ *
14
+ * Listens only while `enabled` (the scroller overflows): a row that fits
15
+ * behaves exactly as before. Keyboard scrolling is untouched.
16
+ */
17
+ export declare function useDragScroll(ref: React.RefObject<HTMLElement | null>, enabled: boolean): void;
@@ -0,0 +1,98 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ /** Pointer travel, in px, before a press on the scroller counts as a drag. */
4
+ export const DRAG_THRESHOLD = 4;
5
+ /**
6
+ * Lets a mouse drag a horizontal scroller sideways, the way touch already
7
+ * does natively. Mouse only (`pointerType === "mouse"`, left button); touch
8
+ * and pen keep the browser's own scrolling. A press becomes a drag after
9
+ * `DRAG_THRESHOLD` px of travel, so a plain click on a tab or link still
10
+ * activates it; once a drag has moved, the click after pointerup is
11
+ * swallowed so the drag never also follows a link. (Radix `TabsTrigger`
12
+ * selects on mousedown, so a drag that starts on an inactive tab still
13
+ * selects that tab on press.)
14
+ *
15
+ * Listens only while `enabled` (the scroller overflows): a row that fits
16
+ * behaves exactly as before. Keyboard scrolling is untouched.
17
+ */
18
+ export function useDragScroll(ref, enabled) {
19
+ React.useEffect(() => {
20
+ const element = ref.current;
21
+ if (!element || !enabled)
22
+ return;
23
+ let pointerId = null;
24
+ let startX = 0;
25
+ let startScrollLeft = 0;
26
+ let dragged = false;
27
+ const onPointerDown = (event) => {
28
+ if (event.pointerType !== "mouse" || event.button !== 0)
29
+ return;
30
+ pointerId = event.pointerId;
31
+ startX = event.clientX;
32
+ startScrollLeft = element.scrollLeft;
33
+ dragged = false;
34
+ };
35
+ const onPointerMove = (event) => {
36
+ if (pointerId === null || event.pointerId !== pointerId)
37
+ return;
38
+ if ((event.buttons & 1) === 0) {
39
+ // The button came up outside the scroller before capture.
40
+ pointerId = null;
41
+ return;
42
+ }
43
+ const delta = event.clientX - startX;
44
+ if (!dragged) {
45
+ if (Math.abs(delta) < DRAG_THRESHOLD)
46
+ return;
47
+ dragged = true;
48
+ // Keep the moves and the up when the pointer leaves the box. The
49
+ // browser releases capture on pointerup and pointercancel.
50
+ element.setPointerCapture(event.pointerId);
51
+ }
52
+ element.scrollLeft = startScrollLeft - delta;
53
+ };
54
+ const onPointerUp = (event) => {
55
+ if (event.pointerId !== pointerId)
56
+ return;
57
+ pointerId = null;
58
+ // `dragged` stays set so the click that follows is swallowed.
59
+ };
60
+ const onPointerCancel = (event) => {
61
+ if (event.pointerId !== pointerId)
62
+ return;
63
+ pointerId = null;
64
+ // No click follows a cancel, so the next real click must go through.
65
+ dragged = false;
66
+ };
67
+ // Capture phase, so it runs before the link's or trigger's own handler.
68
+ const onClick = (event) => {
69
+ if (!dragged)
70
+ return;
71
+ dragged = false;
72
+ event.preventDefault();
73
+ event.stopPropagation();
74
+ };
75
+ // Links are draggable by default: a native drag would cancel the pointer.
76
+ // A text selection started in the row would smear across the labels.
77
+ const onDragOrSelectStart = (event) => {
78
+ if (pointerId !== null)
79
+ event.preventDefault();
80
+ };
81
+ element.addEventListener("pointerdown", onPointerDown);
82
+ element.addEventListener("pointermove", onPointerMove);
83
+ element.addEventListener("pointerup", onPointerUp);
84
+ element.addEventListener("pointercancel", onPointerCancel);
85
+ element.addEventListener("click", onClick, true);
86
+ element.addEventListener("dragstart", onDragOrSelectStart);
87
+ element.addEventListener("selectstart", onDragOrSelectStart);
88
+ return () => {
89
+ element.removeEventListener("pointerdown", onPointerDown);
90
+ element.removeEventListener("pointermove", onPointerMove);
91
+ element.removeEventListener("pointerup", onPointerUp);
92
+ element.removeEventListener("pointercancel", onPointerCancel);
93
+ element.removeEventListener("click", onClick, true);
94
+ element.removeEventListener("dragstart", onDragOrSelectStart);
95
+ element.removeEventListener("selectstart", onDragOrSelectStart);
96
+ };
97
+ }, [ref, enabled]);
98
+ }
@@ -7,17 +7,25 @@ import type { AsChildProps } from "./internal/as-child.js";
7
7
  * Apps pass `active` — the kit has no router.
8
8
  */
9
9
  declare const navLinkVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
10
+ /**
11
+ * Whether `href` is the current page for a nav link: the path itself, or a
12
+ * page under it (`/tasks` is active on `/tasks/42`, not on `/tasks-archive`).
13
+ * The kit has no router, so the app passes the pathname:
14
+ * `active={isNavActive(pathname, href)}`.
15
+ */
16
+ declare function isNavActive(pathname: string, href: string): boolean;
10
17
  /**
11
18
  * Pill link for app-shell destinations (Calendar, Tasks, Settings). Pass
12
- * `active` from the route; the kit does not read the URL. `asChild` wraps a
13
- * router `Link` with these styles, the same Slot pattern as `BreadcrumbLink`.
19
+ * `active` from the route (`isNavActive` compares a pathname with the
20
+ * link's `href`); the kit does not read the URL. `asChild` wraps a router
21
+ * `Link` with these styles, the same Slot pattern as `BreadcrumbLink`.
14
22
  *
15
23
  * Not a tab: in-page view switches that belong in the URL use `TabsNav`
16
- * and `tabsTriggerVariants()`. Next apps can import the optional adapter
24
+ * and `TabsNavLink`. Next apps can import the optional adapter
17
25
  * from `atlas-react-kit/next/nav-link` instead of composing `asChild`.
18
26
  */
19
27
  declare function NavLink({ asChild, active, className, ...props }: React.ComponentProps<"a"> & AsChildProps & {
20
28
  /** This link is the current page: `aria-current="page"` and the selected chrome. */
21
29
  active: boolean;
22
30
  }): React.JSX.Element;
23
- export { NavLink, navLinkVariants };
31
+ export { NavLink, isNavActive, navLinkVariants };
package/dist/nav-link.js CHANGED
@@ -15,17 +15,27 @@ const navLinkVariants = cva([
15
15
  selectedState.active,
16
16
  "inline-flex items-center gap-1.5 rounded-none border border-transparent px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent data-[state=active]:font-medium",
17
17
  ]);
18
+ /**
19
+ * Whether `href` is the current page for a nav link: the path itself, or a
20
+ * page under it (`/tasks` is active on `/tasks/42`, not on `/tasks-archive`).
21
+ * The kit has no router, so the app passes the pathname:
22
+ * `active={isNavActive(pathname, href)}`.
23
+ */
24
+ function isNavActive(pathname, href) {
25
+ return pathname === href || pathname.startsWith(`${href}/`);
26
+ }
18
27
  /**
19
28
  * Pill link for app-shell destinations (Calendar, Tasks, Settings). Pass
20
- * `active` from the route; the kit does not read the URL. `asChild` wraps a
21
- * router `Link` with these styles, the same Slot pattern as `BreadcrumbLink`.
29
+ * `active` from the route (`isNavActive` compares a pathname with the
30
+ * link's `href`); the kit does not read the URL. `asChild` wraps a router
31
+ * `Link` with these styles, the same Slot pattern as `BreadcrumbLink`.
22
32
  *
23
33
  * Not a tab: in-page view switches that belong in the URL use `TabsNav`
24
- * and `tabsTriggerVariants()`. Next apps can import the optional adapter
34
+ * and `TabsNavLink`. Next apps can import the optional adapter
25
35
  * from `atlas-react-kit/next/nav-link` instead of composing `asChild`.
26
36
  */
27
37
  function NavLink({ asChild, active, className, ...props }) {
28
38
  const Comp = asChild ? Slot.Root : "a";
29
39
  return (_jsx(Comp, { "data-slot": "nav-link", "data-state": active ? "active" : "inactive", "aria-current": active ? "page" : undefined, className: cn(navLinkVariants(), className), ...props }));
30
40
  }
31
- export { NavLink, navLinkVariants };
41
+ export { NavLink, isNavActive, navLinkVariants };
@@ -1,9 +1,12 @@
1
1
  import type * as React from "react";
2
2
  import Link from "next/link.js";
3
3
  /**
4
- * Next.js adapter for the shell `NavLink` pill. Same contract as
5
- * `TabsNavLink`: Next `Link` props plus a required `active` boolean. The
6
- * kit still does not read the router; the app passes `active`.
4
+ * Next.js adapter for the shell `NavLink` pill: the base `NavLink` with
5
+ * `asChild` around a Next `Link`, so it renders the same element. Same
6
+ * contract as `TabsNavLink`: Next `Link` props plus a required `active`
7
+ * boolean. The kit still does not read the router; the app passes `active`,
8
+ * usually `active={isNavActive(usePathname(), href)}` with `isNavActive`
9
+ * from `atlas-react-kit/nav-link`.
7
10
  */
8
11
  declare function NavLink({ active, className, ...props }: React.ComponentProps<typeof Link> & {
9
12
  active: boolean;
@@ -1,14 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import Link from "next/link.js";
4
- import { navLinkVariants } from "../nav-link.js";
5
- import { cn } from "../utils.js";
4
+ import { NavLink as BaseNavLink } from "../nav-link.js";
6
5
  /**
7
- * Next.js adapter for the shell `NavLink` pill. Same contract as
8
- * `TabsNavLink`: Next `Link` props plus a required `active` boolean. The
9
- * kit still does not read the router; the app passes `active`.
6
+ * Next.js adapter for the shell `NavLink` pill: the base `NavLink` with
7
+ * `asChild` around a Next `Link`, so it renders the same element. Same
8
+ * contract as `TabsNavLink`: Next `Link` props plus a required `active`
9
+ * boolean. The kit still does not read the router; the app passes `active`,
10
+ * usually `active={isNavActive(usePathname(), href)}` with `isNavActive`
11
+ * from `atlas-react-kit/nav-link`.
10
12
  */
11
13
  function NavLink({ active, className, ...props }) {
12
- return (_jsx(Link, { "data-slot": "nav-link", "data-state": active ? "active" : "inactive", "aria-current": active ? "page" : undefined, className: cn(navLinkVariants(), className), ...props }));
14
+ return (_jsx(BaseNavLink, { asChild: true, active: active, className: className, children: _jsx(Link, { ...props }) }));
13
15
  }
14
16
  export { NavLink };
@@ -1,5 +1,11 @@
1
1
  import type * as React from "react";
2
2
  import Link from "next/link.js";
3
+ /**
4
+ * Next.js adapter for `TabsNavLink`: the base link with `asChild` around a
5
+ * Next `Link`, so it renders the same element. Next `Link` props plus a
6
+ * required `active` boolean (`isNavActive(usePathname(), href)` from
7
+ * `atlas-react-kit/nav-link` works here too).
8
+ */
3
9
  declare function TabsNavLink({ active, className, ...props }: React.ComponentProps<typeof Link> & {
4
10
  active: boolean;
5
11
  }): React.JSX.Element;
@@ -1,9 +1,14 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import Link from "next/link.js";
4
- import { cn } from "../utils.js";
5
- import { tabsTriggerVariants } from "../internal/tabs-styles.js";
4
+ import { TabsNavLink as BaseTabsNavLink } from "../tabs.js";
5
+ /**
6
+ * Next.js adapter for `TabsNavLink`: the base link with `asChild` around a
7
+ * Next `Link`, so it renders the same element. Next `Link` props plus a
8
+ * required `active` boolean (`isNavActive(usePathname(), href)` from
9
+ * `atlas-react-kit/nav-link` works here too).
10
+ */
6
11
  function TabsNavLink({ active, className, ...props }) {
7
- return (_jsx(Link, { "data-slot": "tabs-nav-link", "data-state": active ? "active" : "inactive", "aria-current": active ? "page" : undefined, className: cn(tabsTriggerVariants(), className), ...props }));
12
+ return (_jsx(BaseTabsNavLink, { asChild: true, active: active, className: className, children: _jsx(Link, { ...props }) }));
8
13
  }
9
14
  export { TabsNavLink };