@neasg/design-system 0.4.9 → 0.4.11

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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # NEA Design System
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/%40neasg%2Fdesign-system?style=flat-square&label=npm)](https://www.npmjs.com/package/@neasg/design-system)
4
+
3
5
  Shared NEA UI primitives, theme tokens, and Storybook documentation.
4
6
 
5
7
  ## Local Development
@@ -174,16 +176,14 @@ Release flow:
174
176
 
175
177
  ## Storybook Deployment
176
178
 
177
- `vercel.json` is configured to deploy the built Storybook static site:
178
-
179
- - Install command: `npm ci`
180
- - Build command: `npm run build-storybook`
181
- - Output directory: `storybook-static`
179
+ Storybook is published to Chromatic from GitHub Actions. CI builds Storybook
180
+ with `npm run build-storybook`, then uploads `storybook-static` with
181
+ `npm run chromatic`.
182
182
 
183
- One-time Vercel setup:
183
+ One-time setup:
184
184
 
185
- 1. Import this GitHub repo into Vercel.
186
- 2. Let Vercel use the repo root as the project root.
187
- 3. Keep the build settings from `vercel.json`.
185
+ 1. Create or connect the Chromatic project for this repo.
186
+ 2. Add a GitHub Actions secret named `CHROMATIC_PROJECT_TOKEN`.
188
187
 
189
- After that, every push to the tracked branch deploys Storybook automatically in Vercel.
188
+ After that, pull requests and pushes to `main` publish a Chromatic build. The
189
+ GitHub Actions log includes the published Storybook URL.
package/dist/checkbox.js CHANGED
@@ -8,10 +8,16 @@ const sizeClasses = {
8
8
  md: "size-4",
9
9
  lg: "size-5",
10
10
  };
11
+ const startAlignedOffsetClasses = {
12
+ sm: "mt-[3px]",
13
+ md: "mt-[3px]",
14
+ lg: "mt-0",
15
+ };
11
16
  const Checkbox = React.forwardRef(({ className, indeterminate = false, size = "md", checked, disabled, label, description, labelAlign = "center", id, ...props }, ref) => {
12
17
  const innerRef = React.useRef(null);
13
18
  const generatedId = React.useId();
14
19
  const inputId = id !== null && id !== void 0 ? id : `checkbox-${generatedId}`;
20
+ const alignToStart = labelAlign === "start" || Boolean(description);
15
21
  React.useImperativeHandle(ref, () => innerRef.current);
16
22
  React.useEffect(() => {
17
23
  if (innerRef.current) {
@@ -20,10 +26,10 @@ const Checkbox = React.forwardRef(({ className, indeterminate = false, size = "m
20
26
  }, [indeterminate]);
21
27
  const box = (_jsxs("span", { className: cn("relative inline-block shrink-0 rounded-sm border bg-background align-middle text-primary-foreground transition-colors", sizeClasses[size], (checked || indeterminate) && !disabled
22
28
  ? "border-primary bg-primary"
23
- : "border-input", disabled && "cursor-not-allowed opacity-50", !label && className), children: [_jsx("input", { ref: innerRef, type: "checkbox", id: inputId, checked: checked, disabled: disabled, className: "absolute inset-0 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed", ...props }), indeterminate ? (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round", className: "pointer-events-none absolute inset-0 m-auto h-3 w-3", "aria-hidden": "true", children: _jsx("line", { x1: "5", y1: "12", x2: "19", y2: "12" }) })) : checked ? (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round", strokeLinejoin: "round", className: "pointer-events-none absolute inset-0 m-auto h-3 w-3", "aria-hidden": "true", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) })) : null] }));
29
+ : "border-input", disabled && "cursor-not-allowed opacity-50", label && alignToStart && startAlignedOffsetClasses[size], !label && className), children: [_jsx("input", { ref: innerRef, type: "checkbox", id: inputId, checked: checked, disabled: disabled, className: "absolute inset-0 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed", ...props }), indeterminate ? (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round", className: "pointer-events-none absolute inset-0 m-auto h-3 w-3", "aria-hidden": "true", children: _jsx("line", { x1: "5", y1: "12", x2: "19", y2: "12" }) })) : checked ? (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round", strokeLinejoin: "round", className: "pointer-events-none absolute inset-0 m-auto h-3 w-3", "aria-hidden": "true", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) })) : null] }));
24
30
  if (!label)
25
31
  return box;
26
- return (_jsxs("label", { htmlFor: inputId, className: cn("inline-flex cursor-pointer items-center gap-2 text-sm", labelAlign === "start" && "items-start", disabled && "cursor-not-allowed opacity-50", className), children: [box, _jsxs("div", { className: "select-none", children: [_jsx(Typography, { as: "span", variant: "bodySm", children: label }), description ? (_jsx(Typography, { as: "p", variant: "caption", className: "mt-1", children: description })) : null] })] }));
32
+ return (_jsxs("label", { htmlFor: inputId, className: cn("inline-flex cursor-pointer gap-2 text-sm", alignToStart ? "items-start" : "items-center", disabled && "cursor-not-allowed opacity-50", className), children: [box, _jsxs("div", { className: "flex min-w-0 flex-col gap-0.5 select-none", children: [_jsx(Typography, { as: "span", variant: "bodySm", children: label }), description ? (_jsx(Typography, { as: "p", variant: "caption", color: "muted", children: description })) : null] })] }));
27
33
  });
28
34
  Checkbox.displayName = "Checkbox";
29
35
  export { Checkbox };
@@ -12,6 +12,8 @@ interface DateInputSharedProps {
12
12
  hideLabel?: boolean;
13
13
  placeholder?: string;
14
14
  disabled?: boolean;
15
+ clearable?: boolean;
16
+ clearLabel?: string;
15
17
  minDate?: Date;
16
18
  maxDate?: Date;
17
19
  className?: string;
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { format, parse } from "date-fns";
5
5
  import { CalendarIcon } from "lucide-react";
6
+ import { XIcon } from "./animated-icons/x";
6
7
  import { Calendar } from "./calendar";
7
8
  import { inputControlClassName } from "./input-control";
8
9
  import { Field, FieldDescription, FieldError, FieldLabel } from "./field";
@@ -92,8 +93,9 @@ function formatDisplayRange(range) {
92
93
  }
93
94
  const DateInput = React.forwardRef((props, ref) => {
94
95
  var _a, _b, _c;
95
- const { label, appearance = "field", description, error, invalid = false, hideLabel = false, disabled = false, minDate, maxDate, className, triggerClassName, labelClassName, descriptionClassName, errorClassName, id, } = props;
96
+ const { label, appearance = "field", description, error, invalid = false, hideLabel = false, disabled = false, clearable = false, clearLabel, minDate, maxDate, className, triggerClassName, labelClassName, descriptionClassName, errorClassName, id, } = props;
96
97
  const [open, setOpen] = React.useState(false);
98
+ const [isSelectingRangeEnd, setIsSelectingRangeEnd] = React.useState(false);
97
99
  const generatedId = React.useId();
98
100
  const inputId = id !== null && id !== void 0 ? id : `date-input-${generatedId}`;
99
101
  const errorId = error ? `${inputId}-error` : undefined;
@@ -109,6 +111,24 @@ const DateInput = React.forwardRef((props, ref) => {
109
111
  ? formatDisplayDate(selectedDate)
110
112
  : undefined;
111
113
  const placeholder = (_a = props.placeholder) !== null && _a !== void 0 ? _a : (isRange ? "Pick range" : "Pick a date");
114
+ const showClearButton = clearable && Boolean(displayValue) && !disabled;
115
+ const resolvedClearLabel = clearLabel !== null && clearLabel !== void 0 ? clearLabel : (isRange ? "Clear date range" : "Clear date");
116
+ const handleOpenChange = (nextOpen) => {
117
+ setOpen(nextOpen);
118
+ if (!nextOpen) {
119
+ setIsSelectingRangeEnd(false);
120
+ }
121
+ };
122
+ const handleClear = () => {
123
+ if (props.mode === "range") {
124
+ props.onValueChange({});
125
+ }
126
+ else {
127
+ props.onValueChange("");
128
+ }
129
+ setIsSelectingRangeEnd(false);
130
+ setOpen(false);
131
+ };
112
132
  const handleSelect = (date) => {
113
133
  if (props.mode === "range")
114
134
  return;
@@ -118,17 +138,37 @@ const DateInput = React.forwardRef((props, ref) => {
118
138
  const handleRangeSelect = (range) => {
119
139
  if (props.mode !== "range")
120
140
  return;
141
+ if (!(range === null || range === void 0 ? void 0 : range.from)) {
142
+ props.onValueChange({});
143
+ setIsSelectingRangeEnd(false);
144
+ return;
145
+ }
146
+ const rangeFrom = range.from;
147
+ const rangeTo = range.to;
148
+ const isCompletingRange = isSelectingRangeEnd || Boolean((selectedRange === null || selectedRange === void 0 ? void 0 : selectedRange.from) && !selectedRange.to);
149
+ const isSingleDayRange = rangeTo
150
+ ? getDateTime(rangeFrom) === getDateTime(rangeTo)
151
+ : false;
152
+ if (!isCompletingRange && isSingleDayRange) {
153
+ props.onValueChange({ from: formatISODate(rangeFrom) });
154
+ setIsSelectingRangeEnd(true);
155
+ return;
156
+ }
121
157
  props.onValueChange(formatDateRangeValue(range));
122
- if ((range === null || range === void 0 ? void 0 : range.from) && range.to) {
158
+ if (rangeTo) {
159
+ setIsSelectingRangeEnd(false);
123
160
  setOpen(false);
124
161
  }
162
+ else {
163
+ setIsSelectingRangeEnd(true);
164
+ }
125
165
  };
126
166
  const disabledDays = (date) => isDateOutsideBounds(date, minDate, maxDate);
127
167
  const calendarContent = (_jsx(PopoverContent, { className: "w-auto p-0", align: "start", children: props.mode === "range" ? (_jsx(Calendar, { mode: "range", selected: selectedRange, onSelect: handleRangeSelect, disabled: disabledDays, defaultMonth: (_b = selectedRange === null || selectedRange === void 0 ? void 0 : selectedRange.from) !== null && _b !== void 0 ? _b : selectedRange === null || selectedRange === void 0 ? void 0 : selectedRange.to, numberOfMonths: (_c = props.numberOfMonths) !== null && _c !== void 0 ? _c : 2, autoFocus: true })) : (_jsx(Calendar, { mode: "single", selected: selectedDate, onSelect: handleSelect, disabled: disabledDays, defaultMonth: selectedDate, autoFocus: true })) }));
128
168
  if (appearance === "inline") {
129
- return (_jsxs("span", { className: cn("inline-flex", className), "data-disabled": disabled ? "true" : undefined, "data-invalid": isInvalid ? "true" : undefined, children: [_jsxs(PopoverRoot, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { ref: ref, type: "button", id: inputId, disabled: disabled, "aria-invalid": isInvalid ? "true" : undefined, "aria-describedby": [descriptionId, errorId].filter(Boolean).join(" ") || undefined, className: cn("inline-flex cursor-pointer items-baseline rounded-sm border-0 bg-transparent p-0 font-medium text-primary underline decoration-primary/40 underline-offset-4 transition-colors hover:decoration-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", !displayValue && "text-muted-foreground decoration-border", isInvalid && "text-destructive decoration-destructive", triggerClassName), children: [_jsxs("span", { className: "sr-only", children: [label, ": "] }), _jsx("span", { children: displayValue !== null && displayValue !== void 0 ? displayValue : placeholder })] }) }), calendarContent] }), showDescription ? (_jsx("span", { id: descriptionId, className: "sr-only", children: description })) : null, error ? (_jsx("span", { id: errorId, role: "alert", className: "sr-only", children: error })) : null] }));
169
+ return (_jsxs("span", { className: cn("inline-flex", className), "data-disabled": disabled ? "true" : undefined, "data-invalid": isInvalid ? "true" : undefined, children: [_jsxs(PopoverRoot, { open: open, onOpenChange: handleOpenChange, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { ref: ref, type: "button", id: inputId, disabled: disabled, "aria-invalid": isInvalid ? "true" : undefined, "aria-describedby": [descriptionId, errorId].filter(Boolean).join(" ") || undefined, className: cn("inline-flex cursor-pointer items-baseline rounded-sm border-0 bg-transparent p-0 font-medium text-primary underline decoration-primary/40 underline-offset-4 transition-colors hover:decoration-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", !displayValue && "text-muted-foreground decoration-border", isInvalid && "text-destructive decoration-destructive", triggerClassName), children: [_jsxs("span", { className: "sr-only", children: [label, ": "] }), _jsx("span", { children: displayValue !== null && displayValue !== void 0 ? displayValue : placeholder })] }) }), calendarContent] }), showDescription ? (_jsx("span", { id: descriptionId, className: "sr-only", children: description })) : null, error ? (_jsx("span", { id: errorId, role: "alert", className: "sr-only", children: error })) : null] }));
130
170
  }
131
- return (_jsxs(Field, { className: className, "data-disabled": disabled ? "true" : undefined, "data-invalid": isInvalid ? "true" : undefined, children: [_jsx(FieldLabel, { htmlFor: inputId, className: cn(hideLabel && "sr-only", labelClassName), children: label }), _jsxs(PopoverRoot, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { ref: ref, type: "button", id: inputId, disabled: disabled, "aria-invalid": isInvalid ? "true" : undefined, "aria-describedby": [descriptionId, errorId].filter(Boolean).join(" ") || undefined, className: cn(inputControlClassName, "relative cursor-pointer pr-9 text-left", !displayValue && "text-muted-foreground", isInvalid && "border-destructive focus-visible:ring-destructive", triggerClassName), children: [_jsx("span", { className: "block truncate", children: displayValue !== null && displayValue !== void 0 ? displayValue : placeholder }), _jsx(CalendarIcon, { "aria-hidden": "true", className: "pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground", strokeWidth: 2 })] }) }), calendarContent] }), showDescription ? (_jsx(FieldDescription, { id: descriptionId, className: descriptionClassName, children: description })) : null, _jsx(FieldError, { id: errorId, className: errorClassName, children: error })] }));
171
+ return (_jsxs(Field, { className: className, "data-disabled": disabled ? "true" : undefined, "data-invalid": isInvalid ? "true" : undefined, children: [_jsx(FieldLabel, { htmlFor: inputId, className: cn(hideLabel && "sr-only", labelClassName), children: label }), _jsxs(PopoverRoot, { open: open, onOpenChange: handleOpenChange, children: [_jsxs("div", { className: "relative", children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { ref: ref, type: "button", id: inputId, disabled: disabled, "aria-invalid": isInvalid ? "true" : undefined, "aria-describedby": [descriptionId, errorId].filter(Boolean).join(" ") || undefined, className: cn(inputControlClassName, "relative cursor-pointer pr-9 text-left", showClearButton && "pr-16", !displayValue && "text-muted-foreground", isInvalid && "border-destructive focus-visible:ring-destructive", triggerClassName), children: _jsx("span", { className: "block truncate", children: displayValue !== null && displayValue !== void 0 ? displayValue : placeholder }) }) }), _jsxs("span", { "aria-hidden": showClearButton ? undefined : true, className: "pointer-events-none absolute inset-y-0 right-3 flex items-center gap-2 text-muted-foreground", children: [showClearButton ? (_jsx("button", { type: "button", "aria-label": resolvedClearLabel, onClick: handleClear, className: "pointer-events-auto inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed", children: _jsx(XIcon, { size: 14, className: "flex items-center justify-center" }) })) : null, _jsx(CalendarIcon, { "aria-hidden": "true", className: "h-4 w-4 shrink-0", strokeWidth: 2 })] })] }), calendarContent] }), showDescription ? (_jsx(FieldDescription, { id: descriptionId, className: descriptionClassName, children: description })) : null, _jsx(FieldError, { id: errorId, className: errorClassName, children: error })] }));
132
172
  });
133
173
  DateInput.displayName = "DateInput";
134
174
  export { DateInput };
package/dist/field.d.ts CHANGED
@@ -4,6 +4,10 @@ interface FieldLabelProps extends React.ComponentProps<"label"> {
4
4
  required?: boolean;
5
5
  }
6
6
  declare function FieldLabel({ className, required, children, ...props }: FieldLabelProps): import("react/jsx-runtime").JSX.Element;
7
+ interface FieldLegendProps extends React.ComponentProps<"legend"> {
8
+ required?: boolean;
9
+ }
10
+ declare function FieldLegend({ className, required, children, ...props }: FieldLegendProps): import("react/jsx-runtime").JSX.Element;
7
11
  declare function FieldDescription({ className, ...props }: Omit<React.ComponentProps<"p">, "color">): import("react/jsx-runtime").JSX.Element;
8
12
  declare function FieldError({ className, ...props }: Omit<React.ComponentProps<"p">, "color">): import("react/jsx-runtime").JSX.Element | null;
9
- export { Field, FieldDescription, FieldError, FieldLabel };
13
+ export { Field, FieldDescription, FieldError, FieldLabel, FieldLegend };
package/dist/field.js CHANGED
@@ -7,6 +7,9 @@ function Field({ className, ...props }) {
7
7
  function FieldLabel({ className, required, children, ...props }) {
8
8
  return (_jsx(Typography, { asChild: true, variant: "label", className: cn("group-data-[disabled=true]/field:cursor-not-allowed group-data-[disabled=true]/field:opacity-50 group-data-[invalid=true]/field:text-destructive", className), children: _jsxs("label", { "data-slot": "field-label", ...props, children: [children, required ? (_jsx("span", { "aria-hidden": "true", className: "ml-0.5 text-destructive", children: "*" })) : null] }) }));
9
9
  }
10
+ function FieldLegend({ className, required, children, ...props }) {
11
+ return (_jsx(Typography, { asChild: true, variant: "label", className: cn("group-data-[disabled=true]/field:cursor-not-allowed group-data-[disabled=true]/field:opacity-50 group-data-[invalid=true]/field:text-destructive", className), children: _jsxs("legend", { "data-slot": "field-legend", ...props, children: [children, required ? (_jsx("span", { "aria-hidden": "true", className: "ml-0.5 text-destructive", children: "*" })) : null] }) }));
12
+ }
10
13
  function FieldDescription({ className, ...props }) {
11
14
  return (_jsx(Typography, { as: "p", variant: "bodySm", color: "muted", "data-slot": "field-description", className: className, ...props }));
12
15
  }
@@ -16,4 +19,4 @@ function FieldError({ className, ...props }) {
16
19
  }
17
20
  return (_jsx(Typography, { as: "p", variant: "bodySm", role: "alert", "data-slot": "field-error", className: cn("text-destructive", className), ...props }));
18
21
  }
19
- export { Field, FieldDescription, FieldError, FieldLabel };
22
+ export { Field, FieldDescription, FieldError, FieldLabel, FieldLegend };
@@ -30,8 +30,9 @@ export interface FilterPopoverProps extends Pick<PopoverProps, "align" | "defaul
30
30
  emptyLabel?: React.ReactNode;
31
31
  onReset?: () => void;
32
32
  contentClassName?: string;
33
+ viewClassName?: string;
33
34
  maxHeight?: string;
34
35
  }
35
36
  declare function getFilterSelectionLabel(count: number): string;
36
- declare function FilterPopover({ align, contentClassName, defaultOpen, emptyLabel, groupListLabel, groups, maxHeight, modal, onOpenChange, onReset, open, resetLabel, selectedCount, side, trigger, triggerLabel, }: FilterPopoverProps): import("react/jsx-runtime").JSX.Element;
37
+ declare function FilterPopover({ align, contentClassName, defaultOpen, emptyLabel, groupListLabel, groups, maxHeight, modal, onOpenChange, onReset, open, resetLabel, selectedCount, side, trigger, triggerLabel, viewClassName, }: FilterPopoverProps): import("react/jsx-runtime").JSX.Element;
37
38
  export { FilterPopover, getFilterSelectionLabel };
@@ -32,7 +32,7 @@ function getFilterSelectionLabel(count) {
32
32
  }
33
33
  return count === 1 ? "1 selected" : `${count} selected`;
34
34
  }
35
- function FilterPopover({ align = "start", contentClassName, defaultOpen, emptyLabel = "No options found.", groupListLabel = "Filter by", groups, maxHeight = "max-h-80", modal, onOpenChange, onReset, open, resetLabel = "Reset filters", selectedCount, side, trigger, triggerLabel = "Filters", }) {
35
+ function FilterPopover({ align = "start", contentClassName, defaultOpen, emptyLabel = "No options found.", groupListLabel = "Filter by", groups, maxHeight = "max-h-80", modal, onOpenChange, onReset, open, resetLabel = "Reset filters", selectedCount, side, trigger, triggerLabel = "Filters", viewClassName, }) {
36
36
  var _a, _b, _c, _d, _e;
37
37
  const [activeGroupId, setActiveGroupId] = React.useState(null);
38
38
  const [transitionDirection, setTransitionDirection] = React.useState("forward");
@@ -63,7 +63,7 @@ function FilterPopover({ align = "start", contentClassName, defaultOpen, emptyLa
63
63
  setActiveGroupId(null);
64
64
  };
65
65
  const defaultTrigger = (_jsxs(Button, { type: "button", variant: "outline", children: [_jsx(SlidersHorizontal, { className: "h-4 w-4" }), triggerLabel, resolvedSelectedCount > 0 ? (_jsx(CountBadge, { count: resolvedSelectedCount, size: "sm" })) : null] }));
66
- return (_jsxs(Popover, { align: align, contentClassName: cn("w-[360px]", contentClassName), defaultOpen: defaultOpen, modal: modal, onOpenChange: onOpenChange, open: open, side: side, surface: "menu", trigger: trigger !== null && trigger !== void 0 ? trigger : defaultTrigger, children: [_jsx(PopoverMenuView, { className: "-mx-1 -mt-1 px-1 pt-1", direction: transitionDirection, viewKey: activeGroup ? `group-${activeGroup.id}` : "filter-groups", children: activeGroup ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "-mx-1 -mt-1 border-b p-1", children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "w-full justify-start", onClick: showFilterGroups, children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), activeGroup.label] }) }), activeGroup.searchable ? (_jsx(PopoverMenuSearch, { value: query, onChange: (event) => setQuery(event.target.value), placeholder: (_c = activeGroup.searchPlaceholder) !== null && _c !== void 0 ? _c : `Search ${getGroupLabel(activeGroup).toLowerCase()}...`, "aria-label": (_d = activeGroup.searchPlaceholder) !== null && _d !== void 0 ? _d : `Search ${getGroupLabel(activeGroup).toLowerCase()}` })) : null, _jsx(PopoverMenuScrollArea, { maxHeight: maxHeight, children: _jsx(PopoverMenuSection, { children: filteredOptions.length ? (filteredOptions.map((option) => (_jsx(PopoverMenuItem, { active: option.selected, "aria-label": option.ariaLabel, "aria-pressed": option.selected, disabled: option.disabled, onClick: option.onSelect, children: option.label }, option.id)))) : (_jsx(Typography, { as: "p", variant: "caption", className: "px-2 py-2", children: (_e = activeGroup.emptyLabel) !== null && _e !== void 0 ? _e : emptyLabel })) }) })] })) : (_jsx(PopoverMenuScrollArea, { maxHeight: maxHeight, children: _jsxs(PopoverMenuGroup, { children: [_jsx(PopoverMenuGroupLabel, { children: groupListLabel }), _jsx(PopoverMenuSection, { children: groups.map((group) => {
66
+ return (_jsxs(Popover, { align: align, contentClassName: cn("w-[360px]", contentClassName), defaultOpen: defaultOpen, modal: modal, onOpenChange: onOpenChange, open: open, side: side, surface: "menu", trigger: trigger !== null && trigger !== void 0 ? trigger : defaultTrigger, children: [_jsx(PopoverMenuView, { className: cn("-mx-1 -mt-1 px-1 pt-1", viewClassName), direction: transitionDirection, viewKey: activeGroup ? `group-${activeGroup.id}` : "filter-groups", children: activeGroup ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "-mx-1 -mt-1 border-b p-1", children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "w-full justify-start", onClick: showFilterGroups, children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), activeGroup.label] }) }), activeGroup.searchable ? (_jsx(PopoverMenuSearch, { value: query, onChange: (event) => setQuery(event.target.value), placeholder: (_c = activeGroup.searchPlaceholder) !== null && _c !== void 0 ? _c : `Search ${getGroupLabel(activeGroup).toLowerCase()}...`, "aria-label": (_d = activeGroup.searchPlaceholder) !== null && _d !== void 0 ? _d : `Search ${getGroupLabel(activeGroup).toLowerCase()}` })) : null, _jsx(PopoverMenuScrollArea, { maxHeight: maxHeight, children: _jsx(PopoverMenuSection, { children: filteredOptions.length ? (filteredOptions.map((option) => (_jsx(PopoverMenuItem, { active: option.selected, "aria-label": option.ariaLabel, "aria-pressed": option.selected, disabled: option.disabled, onClick: option.onSelect, children: option.label }, option.id)))) : (_jsx(Typography, { as: "p", variant: "caption", className: "px-2 py-2", children: (_e = activeGroup.emptyLabel) !== null && _e !== void 0 ? _e : emptyLabel })) }) })] })) : (_jsx(PopoverMenuScrollArea, { maxHeight: maxHeight, children: _jsxs(PopoverMenuGroup, { children: [_jsx(PopoverMenuGroupLabel, { children: groupListLabel }), _jsx(PopoverMenuSection, { children: groups.map((group) => {
67
67
  var _a;
68
68
  const groupSelectedCount = getGroupSelectedCount(group);
69
69
  const selectionLabel = (_a = group.selectionLabel) !== null && _a !== void 0 ? _a : getFilterSelectionLabel(groupSelectedCount);
@@ -14,8 +14,14 @@ export interface RadioGroupProps extends Omit<React.FieldsetHTMLAttributes<HTMLF
14
14
  label?: React.ReactNode;
15
15
  hideLabel?: boolean;
16
16
  description?: React.ReactNode;
17
+ error?: React.ReactNode;
18
+ invalid?: boolean;
19
+ required?: boolean;
17
20
  orientation?: "horizontal" | "vertical";
18
21
  groupClassName?: string;
19
22
  optionClassName?: string;
23
+ labelClassName?: string;
24
+ descriptionClassName?: string;
25
+ errorClassName?: string;
20
26
  }
21
27
  export declare const RadioGroup: React.ForwardRefExoticComponent<RadioGroupProps & React.RefAttributes<HTMLFieldSetElement>>;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
+ import { FieldDescription, FieldError, FieldLegend } from "./field";
4
5
  import { cn } from "./lib/utils";
5
6
  import { Typography } from "./typography";
6
7
  function useControllableValue({ value, defaultValue, onValueChange, }) {
@@ -15,27 +16,35 @@ function useControllableValue({ value, defaultValue, onValueChange, }) {
15
16
  }, [isControlled, onValueChange]);
16
17
  return [currentValue, setValue];
17
18
  }
18
- export const RadioGroup = React.forwardRef(({ className, optionClassName, options, value, defaultValue, onValueChange, name, label, hideLabel = false, description, orientation = "horizontal", groupClassName, disabled, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...props }, ref) => {
19
+ export const RadioGroup = React.forwardRef(({ className, optionClassName, options, value, defaultValue, onValueChange, name, label, hideLabel = false, description, error, invalid = false, required = false, orientation = "horizontal", groupClassName, disabled, labelClassName, descriptionClassName, errorClassName, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, ...props }, ref) => {
19
20
  const generatedId = React.useId();
20
21
  const groupName = name !== null && name !== void 0 ? name : `radio-group-${generatedId.replace(/:/g, "")}`;
21
22
  const labelId = label ? `${groupName}-label` : undefined;
22
- const descriptionId = description ? `${groupName}-description` : undefined;
23
+ const errorId = error ? `${groupName}-error` : undefined;
24
+ const showDescription = Boolean(description) && !error;
25
+ const descriptionId = showDescription ? `${groupName}-description` : undefined;
26
+ const isInvalid = invalid ||
27
+ ariaInvalid === true ||
28
+ ariaInvalid === "true" ||
29
+ Boolean(error);
30
+ const describedBy = [ariaDescribedBy, descriptionId, errorId].filter(Boolean).join(" ") ||
31
+ undefined;
23
32
  const [currentValue, setCurrentValue] = useControllableValue({
24
33
  value,
25
34
  defaultValue,
26
35
  onValueChange,
27
36
  });
28
- return (_jsxs("fieldset", { ref: ref, disabled: disabled, className: cn("min-w-0 border-0 p-0", className), ...props, children: [label ? (_jsx("legend", { id: labelId, className: cn("mb-2 text-sm font-medium text-foreground", hideLabel && "sr-only"), children: label })) : null, description ? (_jsx(Typography, { as: "p", id: descriptionId, variant: "caption", color: "muted", className: cn("mb-2", hideLabel && "sr-only"), children: description })) : null, _jsx("div", { role: "radiogroup", "aria-orientation": orientation, "aria-labelledby": labelId !== null && labelId !== void 0 ? labelId : ariaLabelledBy, "aria-describedby": descriptionId !== null && descriptionId !== void 0 ? descriptionId : ariaDescribedBy, className: cn(orientation === "horizontal"
37
+ return (_jsxs("fieldset", { ref: ref, disabled: disabled, "aria-invalid": isInvalid ? "true" : undefined, className: cn("group/field flex w-full min-w-0 flex-col gap-2 border-0 p-0", className), "data-disabled": disabled ? "true" : undefined, "data-invalid": isInvalid ? "true" : undefined, ...props, children: [label ? (_jsx(FieldLegend, { id: labelId, required: required, className: cn("mb-2", hideLabel && "sr-only", labelClassName), children: label })) : null, _jsx("div", { role: "radiogroup", "aria-orientation": orientation, "aria-labelledby": labelId !== null && labelId !== void 0 ? labelId : ariaLabelledBy, "aria-describedby": describedBy, "aria-invalid": isInvalid ? "true" : undefined, className: cn(orientation === "horizontal"
29
38
  ? "flex flex-wrap items-center gap-4"
30
39
  : "grid gap-2", groupClassName), children: options.map((option, optionIndex) => {
31
40
  const optionId = `${groupName}-option-${optionIndex}`;
32
41
  const checked = currentValue === option.value;
33
42
  const optionDisabled = disabled || option.disabled;
34
- return (_jsxs("label", { htmlFor: optionId, className: cn("inline-flex min-w-0 cursor-pointer items-start gap-2 text-sm text-foreground", optionDisabled && "cursor-not-allowed opacity-50", optionClassName), children: [_jsxs("span", { className: cn("relative mt-0.5 inline-flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2", checked ? "border-primary" : "border-input"), children: [_jsx("span", { className: cn("size-2 rounded-full bg-primary transition-transform", checked ? "scale-100" : "scale-0") }), _jsx("input", { id: optionId, type: "radio", name: groupName, value: option.value, checked: checked, disabled: optionDisabled, onChange: (event) => {
43
+ return (_jsxs("label", { htmlFor: optionId, className: cn("inline-flex min-w-0 cursor-pointer items-start gap-2 text-sm text-foreground", optionDisabled && "cursor-not-allowed opacity-50", optionClassName), children: [_jsxs("span", { className: cn("relative mt-[3px] inline-flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2", checked ? "border-primary" : "border-input"), children: [_jsx("span", { className: cn("size-2 rounded-full bg-primary transition-transform", checked ? "scale-100" : "scale-0") }), _jsx("input", { id: optionId, type: "radio", name: groupName, value: option.value, checked: checked, required: required, disabled: optionDisabled, onChange: (event) => {
35
44
  if (event.target.checked) {
36
45
  setCurrentValue(option.value);
37
46
  }
38
- }, className: "absolute inset-0 cursor-pointer opacity-0 disabled:cursor-not-allowed" })] }), _jsxs("span", { className: "min-w-0", children: [_jsx(Typography, { as: "span", variant: "bodySm", children: option.label }), option.description ? (_jsx(Typography, { as: "p", variant: "caption", color: "muted", children: option.description })) : null] })] }, option.value));
39
- }) })] }));
47
+ }, className: "absolute inset-0 cursor-pointer opacity-0 disabled:cursor-not-allowed" })] }), _jsxs("span", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx(Typography, { as: "span", variant: "bodySm", children: option.label }), option.description ? (_jsx(Typography, { as: "p", variant: "caption", color: "muted", children: option.description })) : null] })] }, option.value));
48
+ }) }), showDescription ? (_jsx(FieldDescription, { id: descriptionId, className: descriptionClassName, children: description })) : null, _jsx(FieldError, { id: errorId, className: errorClassName, children: error })] }));
40
49
  });
41
50
  RadioGroup.displayName = "RadioGroup";
package/dist/styles.css CHANGED
@@ -351,6 +351,14 @@
351
351
  --table-border: var(--component-border);
352
352
  }
353
353
 
354
+ sgds-masthead {
355
+ color-scheme: light;
356
+ }
357
+
358
+ .dark sgds-masthead {
359
+ color-scheme: dark;
360
+ }
361
+
354
362
  * {
355
363
  @apply border-border;
356
364
  }
@@ -4,6 +4,7 @@ import type { TableColumn } from "./table";
4
4
  type PopoverPlacementProps = Pick<PopoverProps, "align" | "side" | "sideOffset">;
5
5
  export interface TableColumnVisibilityMenuProps<Row> extends PopoverPlacementProps {
6
6
  columns: TableColumn<Row>[];
7
+ /** Ordered visible column keys. Reordering in the menu emits the next order through `onVisibleColumnKeysChange`. */
7
8
  visibleColumnKeys: readonly string[];
8
9
  onVisibleColumnKeysChange: (visibleColumnKeys: string[]) => void;
9
10
  defaultVisibleColumnKeys?: readonly string[];
@@ -1,7 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
- import { Columns3 } from "lucide-react";
4
+ import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core";
5
+ import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
6
+ import { CSS } from "@dnd-kit/utilities";
7
+ import { Columns3, GripVertical } from "lucide-react";
5
8
  import { Button } from "./button";
6
9
  import { Checkbox } from "./checkbox";
7
10
  import { InputControl } from "./input-control";
@@ -19,11 +22,18 @@ function getColumnVisibilityLabel(column) {
19
22
  }
20
23
  return column.key;
21
24
  }
22
- function orderVisibleColumnKeys(columns, visibleColumnKeys) {
23
- const visibleColumnKeySet = new Set(visibleColumnKeys);
24
- return columns
25
- .map((column) => column.key)
26
- .filter((columnKey) => visibleColumnKeySet.has(columnKey));
25
+ function orderColumnKeys(columns, columnKeys) {
26
+ const columnKeySet = new Set(columns.map((column) => column.key));
27
+ const seenColumnKeys = new Set();
28
+ const orderedColumnKeys = [];
29
+ Array.from(columnKeys).forEach((columnKey) => {
30
+ if (!columnKeySet.has(columnKey) || seenColumnKeys.has(columnKey)) {
31
+ return;
32
+ }
33
+ orderedColumnKeys.push(columnKey);
34
+ seenColumnKeys.add(columnKey);
35
+ });
36
+ return orderedColumnKeys;
27
37
  }
28
38
  function arraysMatch(left, right) {
29
39
  if (left.length !== right.length) {
@@ -31,44 +41,80 @@ function arraysMatch(left, right) {
31
41
  }
32
42
  return left.every((value, index) => value === right[index]);
33
43
  }
44
+ function SortableColumnOption({ columnKey, label, checked, disabled, onToggle, }) {
45
+ const checkboxId = React.useId();
46
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: columnKey, disabled: !checked });
47
+ return (_jsxs("div", { ref: setNodeRef, style: {
48
+ transform: CSS.Transform.toString(transform),
49
+ transition,
50
+ zIndex: isDragging ? 50 : undefined,
51
+ }, className: cn("flex h-9 items-center gap-2 rounded-sm px-1.5 pr-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground", isDragging && "opacity-50 shadow-sm"), children: [_jsx("button", { type: "button", "aria-label": `Reorder ${label}`, disabled: !checked, className: cn("inline-flex size-6 shrink-0 touch-none items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-background hover:text-foreground", checked
52
+ ? "cursor-grab active:cursor-grabbing"
53
+ : "cursor-not-allowed opacity-40"), ...(checked ? { ...attributes, ...listeners } : {}), children: _jsx(GripVertical, { className: "h-4 w-4", "aria-hidden": "true" }) }), _jsx(Checkbox, { id: checkboxId, checked: checked, disabled: disabled, onChange: onToggle, "aria-label": label }), _jsx("label", { htmlFor: checkboxId, className: cn("min-w-0 flex-1 cursor-pointer truncate", disabled && "cursor-not-allowed text-muted-foreground"), children: label })] }));
54
+ }
34
55
  function TableColumnVisibilityMenu({ columns, visibleColumnKeys, onVisibleColumnKeysChange, defaultVisibleColumnKeys, trigger, triggerLabel = "Customize columns", triggerClassName, contentClassName, className, searchPlaceholder = "Search columns", restoreDefaultsLabel = "Restore defaults", emptyMessage = "No columns found", minVisibleColumns = 1, align = "end", sideOffset = 4, side, }) {
35
56
  const [query, setQuery] = React.useState("");
57
+ const sensors = useSensors(useSensor(PointerSensor, {
58
+ activationConstraint: {
59
+ distance: 8,
60
+ },
61
+ }), useSensor(KeyboardSensor, {
62
+ coordinateGetter: sortableKeyboardCoordinates,
63
+ }));
36
64
  const searchInputId = React.useId();
37
65
  const requiredVisibleColumns = Math.max(0, minVisibleColumns);
38
- const visibleColumnKeySet = React.useMemo(() => new Set(visibleColumnKeys), [visibleColumnKeys]);
39
- const visibleColumnCount = columns.filter((column) => visibleColumnKeySet.has(column.key)).length;
40
66
  const selectableColumns = React.useMemo(() => columns.filter((column) => column.hideable !== false), [columns]);
41
- const defaultKeys = React.useMemo(() => orderVisibleColumnKeys(columns, defaultVisibleColumnKeys !== null && defaultVisibleColumnKeys !== void 0 ? defaultVisibleColumnKeys : columns
67
+ const defaultKeys = React.useMemo(() => orderColumnKeys(columns, defaultVisibleColumnKeys !== null && defaultVisibleColumnKeys !== void 0 ? defaultVisibleColumnKeys : columns
42
68
  .filter((column) => column.defaultVisible !== false)
43
69
  .map((column) => column.key)), [columns, defaultVisibleColumnKeys]);
44
- const currentKeys = React.useMemo(() => orderVisibleColumnKeys(columns, visibleColumnKeys), [columns, visibleColumnKeys]);
70
+ const currentKeys = React.useMemo(() => orderColumnKeys(columns, visibleColumnKeys), [columns, visibleColumnKeys]);
71
+ const visibleColumnKeySet = React.useMemo(() => new Set(currentKeys), [currentKeys]);
72
+ const visibleColumnCount = currentKeys.length;
73
+ const orderedSelectableColumns = React.useMemo(() => {
74
+ const selectableColumnsByKey = new Map(selectableColumns.map((column) => [column.key, column]));
75
+ const orderedVisibleColumns = currentKeys
76
+ .map((columnKey) => selectableColumnsByKey.get(columnKey))
77
+ .filter((column) => Boolean(column));
78
+ const orderedVisibleColumnKeySet = new Set(orderedVisibleColumns.map((column) => column.key));
79
+ const hiddenColumns = selectableColumns.filter((column) => !orderedVisibleColumnKeySet.has(column.key));
80
+ return [...orderedVisibleColumns, ...hiddenColumns];
81
+ }, [currentKeys, selectableColumns]);
45
82
  const canRestoreDefaults = defaultKeys.length >= requiredVisibleColumns &&
46
83
  !arraysMatch(currentKeys, defaultKeys);
47
84
  const normalizedQuery = query.trim().toLowerCase();
48
- const filteredColumns = selectableColumns.filter((column) => getColumnVisibilityLabel(column).toLowerCase().includes(normalizedQuery));
85
+ const filteredColumns = orderedSelectableColumns.filter((column) => getColumnVisibilityLabel(column).toLowerCase().includes(normalizedQuery));
49
86
  const commitVisibleColumnKeys = React.useCallback((nextVisibleColumnKeys) => {
50
- const orderedKeys = orderVisibleColumnKeys(columns, nextVisibleColumnKeys);
87
+ const orderedKeys = orderColumnKeys(columns, nextVisibleColumnKeys);
51
88
  if (orderedKeys.length < requiredVisibleColumns) {
52
89
  return;
53
90
  }
54
91
  onVisibleColumnKeysChange(orderedKeys);
55
92
  }, [columns, onVisibleColumnKeysChange, requiredVisibleColumns]);
56
93
  const toggleColumn = React.useCallback((columnKey) => {
57
- const nextVisibleColumnKeys = new Set(visibleColumnKeys);
58
- if (nextVisibleColumnKeys.has(columnKey)) {
59
- nextVisibleColumnKeys.delete(columnKey);
60
- }
61
- else {
62
- nextVisibleColumnKeys.add(columnKey);
94
+ const nextVisibleColumnKeys = currentKeys.filter((currentColumnKey) => currentColumnKey !== columnKey);
95
+ if (!visibleColumnKeySet.has(columnKey)) {
96
+ nextVisibleColumnKeys.push(columnKey);
63
97
  }
64
98
  commitVisibleColumnKeys(nextVisibleColumnKeys);
65
- }, [commitVisibleColumnKeys, visibleColumnKeys]);
99
+ }, [commitVisibleColumnKeys, currentKeys, visibleColumnKeySet]);
100
+ const handleDragEnd = React.useCallback((event) => {
101
+ const { active, over } = event;
102
+ if (!over || active.id === over.id) {
103
+ return;
104
+ }
105
+ const oldIndex = currentKeys.indexOf(String(active.id));
106
+ const newIndex = currentKeys.indexOf(String(over.id));
107
+ if (oldIndex < 0 || newIndex < 0) {
108
+ return;
109
+ }
110
+ commitVisibleColumnKeys(arrayMove(currentKeys, oldIndex, newIndex));
111
+ }, [commitVisibleColumnKeys, currentKeys]);
66
112
  const defaultTrigger = (_jsx(Button, { type: "button", variant: "outline", size: "icon", "aria-label": triggerLabel, className: triggerClassName, children: _jsx(Columns3, { "aria-hidden": "true" }) }));
67
- return (_jsx(Popover, { trigger: trigger !== null && trigger !== void 0 ? trigger : defaultTrigger, align: align, side: side, sideOffset: sideOffset, contentClassName: cn("w-80 p-0", contentClassName), children: _jsxs("div", { className: cn("flex max-h-96 flex-col", className), children: [_jsxs("div", { className: "border-b p-2", children: [_jsx("label", { htmlFor: searchInputId, className: "sr-only", children: searchPlaceholder }), _jsx(SearchInputShell, { children: _jsx(InputControl, { id: searchInputId, value: query, onChange: (event) => setQuery(event.target.value), placeholder: searchPlaceholder, className: "h-8 bg-background pl-8 pr-2" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-1", children: filteredColumns.length ? (filteredColumns.map((column) => {
68
- const label = getColumnVisibilityLabel(column);
69
- const checked = visibleColumnKeySet.has(column.key);
70
- const disabled = checked && visibleColumnCount <= requiredVisibleColumns;
71
- return (_jsxs("label", { className: cn("flex h-9 cursor-pointer items-center gap-2 rounded-sm px-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground", disabled && "cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit"), children: [_jsx(Checkbox, { checked: checked, disabled: disabled, onChange: () => toggleColumn(column.key), "aria-label": label }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: label })] }, column.key));
72
- })) : (_jsx("div", { className: "px-3 py-6 text-center text-sm text-muted-foreground", children: emptyMessage })) }), _jsxs(PopoverMenuFooter, { inset: "flush", className: "justify-between", children: [_jsx(PopoverMenuFooterAction, { onClick: () => commitVisibleColumnKeys(defaultKeys), disabled: !canRestoreDefaults, className: "w-auto min-w-0 flex-1", children: restoreDefaultsLabel }), _jsxs(PopoverMenuFooterMeta, { children: [visibleColumnCount, " of ", columns.length] })] })] }) }));
113
+ return (_jsx(Popover, { trigger: trigger !== null && trigger !== void 0 ? trigger : defaultTrigger, align: align, side: side, sideOffset: sideOffset, contentClassName: cn("w-80 p-0", contentClassName), children: _jsxs("div", { className: cn("flex max-h-96 flex-col", className), children: [_jsxs("div", { className: "border-b p-2", children: [_jsx("label", { htmlFor: searchInputId, className: "sr-only", children: searchPlaceholder }), _jsx(SearchInputShell, { children: _jsx(InputControl, { id: searchInputId, value: query, onChange: (event) => setQuery(event.target.value), placeholder: searchPlaceholder, className: "h-8 bg-background pl-8 pr-2" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-1", children: filteredColumns.length ? (_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: filteredColumns.map((column) => column.key), strategy: verticalListSortingStrategy, children: filteredColumns.map((column) => {
114
+ const label = getColumnVisibilityLabel(column);
115
+ const checked = visibleColumnKeySet.has(column.key);
116
+ const disabled = checked && visibleColumnCount <= requiredVisibleColumns;
117
+ return (_jsx(SortableColumnOption, { columnKey: column.key, label: label, checked: checked, disabled: disabled, onToggle: () => toggleColumn(column.key) }, column.key));
118
+ }) }) })) : (_jsx("div", { className: "px-3 py-6 text-center text-sm text-muted-foreground", children: emptyMessage })) }), _jsxs(PopoverMenuFooter, { inset: "flush", className: "justify-between", children: [_jsx(PopoverMenuFooterAction, { onClick: () => commitVisibleColumnKeys(defaultKeys), disabled: !canRestoreDefaults, className: "w-auto min-w-0 flex-1", children: restoreDefaultsLabel }), _jsxs(PopoverMenuFooterMeta, { children: [visibleColumnCount, " of ", columns.length] })] })] }) }));
73
119
  }
74
120
  export { TableColumnVisibilityMenu };
package/dist/table.d.ts CHANGED
@@ -115,7 +115,7 @@ export interface TableProps<Row> extends Omit<React.HTMLAttributes<HTMLDivElemen
115
115
  pagination?: Omit<PaginationProps, "className">;
116
116
  loading?: boolean;
117
117
  loadingRows?: number;
118
- /** Optional controlled set of visible column keys. Omit to render every column. */
118
+ /** Optional controlled ordered list of visible column keys. Omit to render every column in config order. */
119
119
  visibleColumnKeys?: readonly string[];
120
120
  /** When true, applies alternating row backgrounds for easier scanning. */
121
121
  striped?: boolean;
package/dist/table.js CHANGED
@@ -130,16 +130,30 @@ function tableRowToneClassName(tone) {
130
130
  return undefined;
131
131
  }
132
132
  }
133
+ function getVisibleColumns(columns, visibleColumnKeys) {
134
+ if (!visibleColumnKeys) {
135
+ return columns;
136
+ }
137
+ const columnsByKey = new Map(columns.map((column) => [column.key, column]));
138
+ const seenColumnKeys = new Set();
139
+ const visibleColumns = [];
140
+ visibleColumnKeys.forEach((columnKey) => {
141
+ if (seenColumnKeys.has(columnKey)) {
142
+ return;
143
+ }
144
+ const column = columnsByKey.get(columnKey);
145
+ if (column) {
146
+ visibleColumns.push(column);
147
+ seenColumnKeys.add(columnKey);
148
+ }
149
+ });
150
+ return visibleColumns;
151
+ }
133
152
  function Table({ columns, rows, getRowKey, emptyState = (_jsx(EmptyState, { message: "No data available", description: "There are no rows to display in this table yet.", className: "border-0 bg-transparent px-0 py-6" })), toolbar, caption, className, containerClassName, tableClassName, rowClassName, rowTone, maxHeight, sorting, pagination, loading = false, loadingRows = 5, visibleColumnKeys, striped = false, onRowClick, style, ...props }) {
134
153
  // Use auto layout so the browser can grow a column to contain its (nowrap)
135
154
  // header. Caller can opt back into fixed layout via `tableClassName="table-fixed"`.
136
155
  const shouldUseFixedLayout = false;
137
- const visibleColumnSet = visibleColumnKeys
138
- ? new Set(visibleColumnKeys)
139
- : undefined;
140
- const visibleColumns = visibleColumnSet
141
- ? columns.filter((column) => visibleColumnSet.has(column.key))
142
- : columns;
156
+ const visibleColumns = getVisibleColumns(columns, visibleColumnKeys);
143
157
  const container = (_jsx(TableContainer, { className: cn("w-full", loading && "pointer-events-none select-none", className, containerClassName), style: {
144
158
  ...style,
145
159
  ...(maxHeight !== undefined ? { maxHeight } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neasg/design-system",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "NEA shared design system primitives, theme tokens, and Storybook docs.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -22,7 +22,8 @@
22
22
  "storybook": "storybook dev -p 6007 -c .storybook",
23
23
  "build-storybook": "storybook build -c .storybook",
24
24
  "test": "vitest run",
25
- "prepublishOnly": "npm test && npm run build"
25
+ "prepublishOnly": "npm test && npm run build",
26
+ "chromatic": "chromatic --build-script-name build-storybook"
26
27
  },
27
28
  "publishConfig": {
28
29
  "access": "public",
@@ -85,6 +86,7 @@
85
86
  "@types/react-dom": "^18.2.18",
86
87
  "@vitejs/plugin-react": "^6.0.1",
87
88
  "autoprefixer": "^10.4.16",
89
+ "chromatic": "^16.10.1",
88
90
  "jsdom": "^29.0.0",
89
91
  "postcss": "^8.4.31",
90
92
  "react": "^18.2.0",