aplance-ui 1.0.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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/api.d.ts +175 -0
- package/dist/api.js +1954 -0
- package/dist/api.js.map +1 -0
- package/dist/frameworks/astro/astro.config.snippet.md +23 -0
- package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
- package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
- package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
- package/dist/frameworks/next/layout-import.snippet.md +19 -0
- package/dist/frameworks/next/next.config.snippet.md +15 -0
- package/dist/frameworks/next/postcss.config.js.template +16 -0
- package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
- package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
- package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
- package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
- package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
- package/dist/index.js +2047 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/registry/components/primitives/icon/icon.module.css +63 -0
- package/registry/components/primitives/icon/icon.tsx +53 -0
- package/registry/components/primitives/modal/modal-context.ts +19 -0
- package/registry/components/primitives/modal/modal.module.css +415 -0
- package/registry/components/primitives/modal/modal.tsx +550 -0
- package/registry/components/primitives/popover/popover-context.ts +28 -0
- package/registry/components/primitives/popover/popover.module.css +28 -0
- package/registry/components/primitives/popover/popover.tsx +465 -0
- package/registry/components/ui/accordion/accordion.module.css +208 -0
- package/registry/components/ui/accordion/accordion.tsx +327 -0
- package/registry/components/ui/avatar/avatar.module.css +32 -0
- package/registry/components/ui/avatar/avatar.tsx +37 -0
- package/registry/components/ui/badge/badge.module.css +103 -0
- package/registry/components/ui/badge/badge.tsx +29 -0
- package/registry/components/ui/button/button.module.css +216 -0
- package/registry/components/ui/button/button.tsx +133 -0
- package/registry/components/ui/chart/chart.module.css +168 -0
- package/registry/components/ui/chart/chart.tsx +1293 -0
- package/registry/components/ui/chart/types.ts +210 -0
- package/registry/components/ui/chart/utils.ts +59 -0
- package/registry/components/ui/copy-button/copy-button.module.css +21 -0
- package/registry/components/ui/copy-button/copy-button.tsx +123 -0
- package/registry/components/ui/countdown/countdown.module.css +5 -0
- package/registry/components/ui/countdown/countdown.tsx +152 -0
- package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
- package/registry/components/ui/data-table/data-table.module.css +594 -0
- package/registry/components/ui/data-table/data-table.tsx +1109 -0
- package/registry/components/ui/data-table/filter-bar.tsx +33 -0
- package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
- package/registry/components/ui/data-table/types.ts +594 -0
- package/registry/components/ui/data-table/useDataTable.tsx +573 -0
- package/registry/components/ui/data-table/utils.ts +445 -0
- package/registry/components/ui/empty-state/empty-state.module.css +26 -0
- package/registry/components/ui/empty-state/empty-state.tsx +32 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
- package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
- package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
- package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
- package/registry/components/ui/input-fields/common/types.ts +38 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
- package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
- package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
- package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
- package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
- package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
- package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
- package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
- package/registry/components/ui/input-fields/input/input.tsx +319 -0
- package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
- package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
- package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
- package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
- package/registry/components/ui/input-fields/media-input/types.ts +129 -0
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
- package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
- package/registry/components/ui/input-fields/select/select.tsx +118 -0
- package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
- package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
- package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
- package/registry/components/ui/loader/loader.module.css +156 -0
- package/registry/components/ui/loader/loader.tsx +102 -0
- package/registry/components/ui/stepper/stepper.module.css +294 -0
- package/registry/components/ui/stepper/stepper.tsx +197 -0
- package/registry/components/ui/tabs/tabs.module.css +150 -0
- package/registry/components/ui/tabs/tabs.tsx +134 -0
- package/registry/components/ui/toast/toast.module.css +159 -0
- package/registry/components/ui/toast/toast.tsx +104 -0
- package/registry/components/ui/tooltip/tooltip.module.css +82 -0
- package/registry/components/ui/tooltip/tooltip.tsx +72 -0
- package/registry/global.d.ts +4 -0
- package/registry/hooks/useFocusOutside.tsx +33 -0
- package/registry/hooks/useResizeObserver.tsx +32 -0
- package/registry/lib/dom.ts +25 -0
- package/registry/lib/string.ts +23 -0
- package/registry/registry.json +719 -0
- package/registry/styles/app.css +240 -0
- package/registry/styles/components.css +78 -0
- package/registry/styles/fonts/geist-regular.woff2 +0 -0
- package/registry/styles/globals.css +7 -0
- package/registry/styles/media.css +25 -0
- package/registry/styles/utilities.css +90 -0
- package/registry/themes/colors/blue.css +37 -0
- package/registry/themes/colors/green.css +37 -0
- package/registry/themes/colors/rose.css +37 -0
- package/registry/themes/colors/slate.css +37 -0
- package/registry/themes/colors/stone.css +37 -0
- package/registry/themes/colors/violet.css +37 -0
- package/registry/themes/colors/zinc.css +37 -0
- package/registry/themes/radius/pill.css +8 -0
- package/registry/themes/radius/rounded.css +8 -0
- package/registry/themes/radius/sharp.css +8 -0
- package/registry/themes/spacing/compact.css +17 -0
- package/registry/themes/spacing/spacious.css +17 -0
- package/registry/tsconfig.json +25 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import type { Matcher } from "react-day-picker";
|
|
3
|
+
import type { BaseInputFieldProps } from "../common/types";
|
|
4
|
+
import type {
|
|
5
|
+
Align,
|
|
6
|
+
Popover,
|
|
7
|
+
PopoverMenu,
|
|
8
|
+
Position,
|
|
9
|
+
PopoverTrigger,
|
|
10
|
+
} from "../../../primitives/popover/popover";
|
|
11
|
+
import type { SelectButtonProps } from "../common/select-button/select-button";
|
|
12
|
+
import type { CalendarProps } from "./calendar";
|
|
13
|
+
|
|
14
|
+
/** Inclusive date range. Both ends are optional to support partial selections. */
|
|
15
|
+
export type DateRange = {
|
|
16
|
+
from?: Date;
|
|
17
|
+
to?: Date;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const datePickerModes = ["single", "range"] as const;
|
|
21
|
+
export type DatePickerMode = (typeof datePickerModes)[number];
|
|
22
|
+
|
|
23
|
+
export type CommonDatePickerProps = Omit<
|
|
24
|
+
BaseInputFieldProps,
|
|
25
|
+
"buttonStyle"
|
|
26
|
+
> & {
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
startPlaceholder?: string;
|
|
29
|
+
endPlaceholder?: string;
|
|
30
|
+
|
|
31
|
+
/** Disable days before this date. */
|
|
32
|
+
minDate?: Date;
|
|
33
|
+
/** Disable days after this date. */
|
|
34
|
+
maxDate?: Date;
|
|
35
|
+
/** Any additional react-day-picker matcher(s) for disabled days. */
|
|
36
|
+
disabledDates?: Matcher | Matcher[];
|
|
37
|
+
|
|
38
|
+
/** Close popover after selection (default: false). */
|
|
39
|
+
closeOnSelection?: boolean;
|
|
40
|
+
|
|
41
|
+
/** Show date input(s) above the calendar for direct keyboard entry (default: true). */
|
|
42
|
+
showManualInput?: boolean;
|
|
43
|
+
|
|
44
|
+
/** Show HH:mm input(s) below the calendar and include time in the value. */
|
|
45
|
+
showTime?: boolean;
|
|
46
|
+
/** Optional `step` forwarded to the time input(s) (seconds). */
|
|
47
|
+
timeStep?: number;
|
|
48
|
+
/** Time panel display format. Internal value remains 24h Date-based. */
|
|
49
|
+
timeFormat?: "24h" | "12h";
|
|
50
|
+
/** Configure nested time picker popover placement while keeping DatePicker defaults. */
|
|
51
|
+
timePickerPopover?: {
|
|
52
|
+
position?: Position;
|
|
53
|
+
align?: Align;
|
|
54
|
+
};
|
|
55
|
+
/** Show popover footer actions (Clear/Done) in DatePicker. Default: true. */
|
|
56
|
+
showActionFooter?: boolean;
|
|
57
|
+
|
|
58
|
+
/** Show a clear button on the trigger when a value is set (default: true). */
|
|
59
|
+
showClear?: boolean;
|
|
60
|
+
onClear?: () => void;
|
|
61
|
+
onBlur?: () => void;
|
|
62
|
+
|
|
63
|
+
/** How many months the calendar renders. Defaults: single=1, range=2. */
|
|
64
|
+
numberOfMonths?: number;
|
|
65
|
+
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
66
|
+
/** Passed through to react-day-picker. */
|
|
67
|
+
captionLayout?: CalendarProps["captionLayout"];
|
|
68
|
+
showWeekNumber?: boolean;
|
|
69
|
+
|
|
70
|
+
/** Override the trigger display. Receives the current value + context. */
|
|
71
|
+
formatDisplay?: (
|
|
72
|
+
value: Date | DateRange | undefined,
|
|
73
|
+
context: { mode: DatePickerMode; withTime: boolean },
|
|
74
|
+
) => string;
|
|
75
|
+
|
|
76
|
+
/** Override how a single `Date` is formatted (used by the default display). */
|
|
77
|
+
formatDate?: (date: Date, context: { withTime: boolean }) => string;
|
|
78
|
+
|
|
79
|
+
popover?: Omit<ComponentProps<typeof Popover>, "children">;
|
|
80
|
+
popoverTrigger?: ComponentProps<typeof PopoverTrigger>;
|
|
81
|
+
popoverMenu?: ComponentProps<typeof PopoverMenu>;
|
|
82
|
+
selectButton?: Omit<SelectButtonProps, "children" | "onClear">;
|
|
83
|
+
calendar?: Omit<CalendarProps, "mode" | "selected" | "onSelect">;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type SingleDatePickerProps = CommonDatePickerProps & {
|
|
87
|
+
mode?: "single";
|
|
88
|
+
value?: Date;
|
|
89
|
+
defaultValue?: Date;
|
|
90
|
+
onChange?: (date: Date | undefined) => void;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type RangeDatePickerProps = CommonDatePickerProps & {
|
|
94
|
+
mode: "range";
|
|
95
|
+
value?: DateRange;
|
|
96
|
+
defaultValue?: DateRange;
|
|
97
|
+
onChange?: (range: DateRange | undefined) => void;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type DatePickerProps =
|
|
101
|
+
| SingleDatePickerProps
|
|
102
|
+
| RangeDatePickerProps;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { DatePickerMode, DateRange } from "./types";
|
|
2
|
+
|
|
3
|
+
export type InternalValue = Date | DateRange | undefined;
|
|
4
|
+
|
|
5
|
+
const pad2 = (value: number): string => String(value).padStart(2, "0");
|
|
6
|
+
|
|
7
|
+
export const isValidDate = (value: unknown): value is Date =>
|
|
8
|
+
value instanceof Date && !Number.isNaN(value.getTime());
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a Date for the given calendar day, anchored at 12:00 UTC.
|
|
12
|
+
* Why: anchoring to UTC noon keeps `toISOString()` on the intended day for
|
|
13
|
+
* users in any timezone within ~±11h of UTC - local midnight crosses the day
|
|
14
|
+
* boundary when serialized east of UTC, which is the classic cause of
|
|
15
|
+
* "selected Apr 3 but JSON says Apr 2T18:30Z". Returns undefined for invalid
|
|
16
|
+
* parts (e.g. Feb 30).
|
|
17
|
+
*/
|
|
18
|
+
const fromCalendarParts = (
|
|
19
|
+
year: number,
|
|
20
|
+
month: number,
|
|
21
|
+
day: number,
|
|
22
|
+
): Date | undefined => {
|
|
23
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return undefined;
|
|
24
|
+
const date = new Date(Date.UTC(year, month - 1, day, 12, 0, 0, 0));
|
|
25
|
+
if (
|
|
26
|
+
date.getUTCFullYear() !== year ||
|
|
27
|
+
date.getUTCMonth() !== month - 1 ||
|
|
28
|
+
date.getUTCDate() !== day
|
|
29
|
+
) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return date;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Anchor `date` to its calendar day in a timezone-safe way so that
|
|
37
|
+
* `toISOString()` preserves the user-visible day. Use for date-only picker
|
|
38
|
+
* values; do not use when a specific time of day is meaningful.
|
|
39
|
+
*/
|
|
40
|
+
export const startOfDay = (date: Date): Date =>
|
|
41
|
+
new Date(
|
|
42
|
+
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0, 0),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** "HH:mm" string for the time portion of `date`. */
|
|
46
|
+
export const toTimeString = (date: Date): string =>
|
|
47
|
+
`${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
|
48
|
+
|
|
49
|
+
/** "YYYY-MM-DD" string for native date input controls. */
|
|
50
|
+
export const toDateInputString = (date: Date): string =>
|
|
51
|
+
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
52
|
+
|
|
53
|
+
/** Parse a "YYYY-MM-DD" date-input string into a calendar-day Date. */
|
|
54
|
+
export const parseDateInputString = (value: string): Date | undefined => {
|
|
55
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
56
|
+
if (!match) return undefined;
|
|
57
|
+
return fromCalendarParts(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse plain-text date input.
|
|
62
|
+
* Supported formats:
|
|
63
|
+
* - YYYY-MM-DD
|
|
64
|
+
* - DD/MM/YYYY
|
|
65
|
+
* - DD-MM-YYYY
|
|
66
|
+
* - DD.MM.YYYY
|
|
67
|
+
*/
|
|
68
|
+
export const parseDateTextString = (value: string): Date | undefined => {
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
if (!trimmed) return undefined;
|
|
71
|
+
|
|
72
|
+
const fromIso = parseDateInputString(trimmed);
|
|
73
|
+
if (fromIso) return fromIso;
|
|
74
|
+
|
|
75
|
+
const match = /^(\d{1,2})[./-](\d{1,2})[./-](\d{4})$/.exec(trimmed);
|
|
76
|
+
if (!match) return undefined;
|
|
77
|
+
return fromCalendarParts(Number(match[3]), Number(match[2]), Number(match[1]));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Parse "HH:mm" (or "HH:mm:ss") into {hours, minutes}. */
|
|
81
|
+
export const parseTimeString = (
|
|
82
|
+
value: string,
|
|
83
|
+
): { hours: number; minutes: number } | undefined => {
|
|
84
|
+
const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
|
|
85
|
+
if (!match) return undefined;
|
|
86
|
+
|
|
87
|
+
const hours = Number(match[1]);
|
|
88
|
+
const minutes = Number(match[2]);
|
|
89
|
+
|
|
90
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { hours, minutes };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Return a new Date with the time portion replaced by `time` ("HH:mm"). */
|
|
98
|
+
export const withTimeString = (date: Date, time: string): Date => {
|
|
99
|
+
const parsed = parseTimeString(time);
|
|
100
|
+
const next = new Date(date);
|
|
101
|
+
if (!parsed) {
|
|
102
|
+
next.setHours(0, 0, 0, 0);
|
|
103
|
+
return next;
|
|
104
|
+
}
|
|
105
|
+
next.setHours(parsed.hours, parsed.minutes, 0, 0);
|
|
106
|
+
return next;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Return a new Date that keeps `date`'s calendar day but adopts `time`'s H/M. */
|
|
110
|
+
export const mergeDateWithTimeOf = (date: Date, time: Date | undefined): Date => {
|
|
111
|
+
const next = new Date(date);
|
|
112
|
+
if (time && isValidDate(time)) {
|
|
113
|
+
next.setHours(time.getHours(), time.getMinutes(), 0, 0);
|
|
114
|
+
} else {
|
|
115
|
+
next.setHours(0, 0, 0, 0);
|
|
116
|
+
}
|
|
117
|
+
return next;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Default `Intl`-based display for a single date. */
|
|
121
|
+
export const defaultFormatDate = (
|
|
122
|
+
date: Date,
|
|
123
|
+
{ withTime }: { withTime: boolean },
|
|
124
|
+
): string => {
|
|
125
|
+
try {
|
|
126
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
127
|
+
day: "2-digit",
|
|
128
|
+
month: "short",
|
|
129
|
+
year: "numeric",
|
|
130
|
+
...(withTime
|
|
131
|
+
? ({ hour: "2-digit", minute: "2-digit" } as const)
|
|
132
|
+
: undefined),
|
|
133
|
+
}).format(date);
|
|
134
|
+
} catch {
|
|
135
|
+
return date.toDateString();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const isRangeValue = (
|
|
140
|
+
value: Date | DateRange | undefined,
|
|
141
|
+
): value is DateRange =>
|
|
142
|
+
!!value &&
|
|
143
|
+
typeof value === "object" &&
|
|
144
|
+
!(value instanceof Date) &&
|
|
145
|
+
("from" in value || "to" in value);
|
|
146
|
+
|
|
147
|
+
export const hasRangeValue = (range: DateRange | undefined): boolean =>
|
|
148
|
+
Boolean(range?.from || range?.to);
|
|
149
|
+
|
|
150
|
+
/** Normalize a single Date input, optionally anchoring to the calendar day. */
|
|
151
|
+
export const coerceToDate = (
|
|
152
|
+
value: Date | undefined,
|
|
153
|
+
anchorToDay: boolean,
|
|
154
|
+
): Date | undefined => {
|
|
155
|
+
if (!isValidDate(value)) return undefined;
|
|
156
|
+
return anchorToDay ? startOfDay(value) : value;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Normalize a DateRange input; returns undefined when both ends are empty. */
|
|
160
|
+
export const coerceToRange = (
|
|
161
|
+
value: DateRange | undefined,
|
|
162
|
+
anchorToDay: boolean,
|
|
163
|
+
): DateRange | undefined => {
|
|
164
|
+
if (!value) return undefined;
|
|
165
|
+
const from = coerceToDate(value.from, anchorToDay);
|
|
166
|
+
const to = coerceToDate(value.to, anchorToDay);
|
|
167
|
+
if (!from && !to) return undefined;
|
|
168
|
+
return { from, to };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** First day of `value`'s month, in local time. */
|
|
172
|
+
export const toMonthStart = (value: Date): Date =>
|
|
173
|
+
new Date(value.getFullYear(), value.getMonth(), 1);
|
|
174
|
+
|
|
175
|
+
/** First day of the month `amount` offsets from `value`. */
|
|
176
|
+
export const addMonthOffset = (value: Date, amount: number): Date =>
|
|
177
|
+
new Date(value.getFullYear(), value.getMonth() + amount, 1);
|
|
178
|
+
|
|
179
|
+
/** True when both dates fall in the same calendar year + month. */
|
|
180
|
+
export const isSameMonth = (a: Date | undefined, b: Date | undefined): boolean =>
|
|
181
|
+
Boolean(
|
|
182
|
+
a &&
|
|
183
|
+
b &&
|
|
184
|
+
a.getFullYear() === b.getFullYear() &&
|
|
185
|
+
a.getMonth() === b.getMonth(),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
/** Build the trigger button text for the current value, falling back to placeholders. */
|
|
189
|
+
export const formatTriggerDisplay = (
|
|
190
|
+
value: InternalValue,
|
|
191
|
+
mode: DatePickerMode,
|
|
192
|
+
withTime: boolean,
|
|
193
|
+
placeholder: string,
|
|
194
|
+
startPlaceholder: string,
|
|
195
|
+
endPlaceholder: string,
|
|
196
|
+
formatDate: (date: Date, ctx: { withTime: boolean }) => string,
|
|
197
|
+
): string => {
|
|
198
|
+
if (mode === "range") {
|
|
199
|
+
const range = isRangeValue(value) ? value : undefined;
|
|
200
|
+
const start = range?.from;
|
|
201
|
+
const end = range?.to;
|
|
202
|
+
|
|
203
|
+
if (!start && !end) return placeholder;
|
|
204
|
+
|
|
205
|
+
const startText = start
|
|
206
|
+
? formatDate(start, { withTime })
|
|
207
|
+
: startPlaceholder;
|
|
208
|
+
const endText = end ? formatDate(end, { withTime }) : endPlaceholder;
|
|
209
|
+
|
|
210
|
+
return `${startText} - ${endText}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const single = value instanceof Date ? value : undefined;
|
|
214
|
+
if (!single) return placeholder;
|
|
215
|
+
return formatDate(single, { withTime });
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Auto-format a date string as the user types: inserts `-` separators
|
|
220
|
+
* so that `20260419` becomes `2026-04-19`.
|
|
221
|
+
*/
|
|
222
|
+
export const autoFormatDateText = (raw: string, previousText: string): string => {
|
|
223
|
+
const digits = raw.replace(/\D/g, "");
|
|
224
|
+
|
|
225
|
+
// If user deleted a separator, don't re-insert it (allow backspace).
|
|
226
|
+
const prevDigits = previousText.replace(/\D/g, "");
|
|
227
|
+
if (digits.length < prevDigits.length) return raw;
|
|
228
|
+
|
|
229
|
+
let formatted = "";
|
|
230
|
+
for (let i = 0; i < digits.length && i < 8; i++) {
|
|
231
|
+
if (i === 4 || i === 6) formatted += "-";
|
|
232
|
+
formatted += digits[i];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return formatted;
|
|
236
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* Emoji palette - fully custom UI, themed via family tokens.
|
|
2
|
+
Search bar + scrollable viewport with sticky category headers + grid. */
|
|
3
|
+
|
|
4
|
+
.root {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 22rem;
|
|
9
|
+
background-color: var(--color-background);
|
|
10
|
+
font-family: var(--font-family-1);
|
|
11
|
+
isolation: isolate;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* ── Search ─────────────────────────────────────────────────────────────── */
|
|
15
|
+
|
|
16
|
+
.search_wrap {
|
|
17
|
+
padding: var(--spacing-4);
|
|
18
|
+
border-bottom: 1px solid var(--color-border);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* ── Viewport ───────────────────────────────────────────────────────────── */
|
|
22
|
+
|
|
23
|
+
.viewport {
|
|
24
|
+
flex: 1;
|
|
25
|
+
min-height: 0;
|
|
26
|
+
overflow-y: auto;
|
|
27
|
+
scrollbar-width: thin;
|
|
28
|
+
padding: var(--spacing-3);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* ── Categories ─────────────────────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
.category + .category {
|
|
34
|
+
margin-top: var(--spacing-3);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.category_header {
|
|
38
|
+
position: sticky;
|
|
39
|
+
top: calc(var(--spacing-3) * -1);
|
|
40
|
+
z-index: 1;
|
|
41
|
+
margin: 0;
|
|
42
|
+
padding: var(--spacing-3) var(--spacing-2) var(--spacing-2);
|
|
43
|
+
background-color: var(--color-background);
|
|
44
|
+
color: var(--color-muted-foreground);
|
|
45
|
+
font-size: var(--font-size-xs);
|
|
46
|
+
font-weight: var(--font-weight-medium);
|
|
47
|
+
letter-spacing: 0.02em;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ── Grid + emoji buttons ───────────────────────────────────────────────── */
|
|
51
|
+
|
|
52
|
+
.grid {
|
|
53
|
+
display: grid;
|
|
54
|
+
gap: var(--spacing-1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.emoji {
|
|
58
|
+
display: inline-flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: center;
|
|
61
|
+
aspect-ratio: 1;
|
|
62
|
+
padding: 0;
|
|
63
|
+
border: 0;
|
|
64
|
+
border-radius: var(--radius-md);
|
|
65
|
+
background: transparent;
|
|
66
|
+
font-size: 1.25rem;
|
|
67
|
+
line-height: 1;
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
transition: background-color var(--transition-fast);
|
|
70
|
+
|
|
71
|
+
&:hover {
|
|
72
|
+
background-color: var(--color-muted);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
&:focus-visible {
|
|
76
|
+
outline: none;
|
|
77
|
+
box-shadow: var(--shadow-ring);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ── Empty state ────────────────────────────────────────────────────────── */
|
|
82
|
+
|
|
83
|
+
.empty {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
justify-content: center;
|
|
87
|
+
height: 100%;
|
|
88
|
+
color: var(--color-muted-foreground);
|
|
89
|
+
font-size: var(--font-size-sm);
|
|
90
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
} from "react";
|
|
9
|
+
import clsx from "clsx";
|
|
10
|
+
import { Input } from "../input/input";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_EMOJI_CATEGORIES,
|
|
13
|
+
searchEmojis,
|
|
14
|
+
type EmojiCategory,
|
|
15
|
+
type EmojiEntry,
|
|
16
|
+
} from "./utils";
|
|
17
|
+
import s from "./emoji-palette.module.css";
|
|
18
|
+
|
|
19
|
+
export type EmojiPaletteProps = {
|
|
20
|
+
/** Fires with the picked emoji character. */
|
|
21
|
+
onSelect: (emoji: string) => void;
|
|
22
|
+
/** Custom dataset. Defaults to ~300 curated emojis bundled inline. */
|
|
23
|
+
categories?: readonly EmojiCategory[];
|
|
24
|
+
/** Number of grid columns. Default: 9. */
|
|
25
|
+
columns?: number;
|
|
26
|
+
/** Search field placeholder. Default: "Search emojis…". */
|
|
27
|
+
searchPlaceholder?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
style?: CSSProperties;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Standalone emoji palette - search + categorized grid + keyboard navigation.
|
|
34
|
+
* Renderable on its own (e.g. inline in a sidebar) or inside any popover.
|
|
35
|
+
* Custom-built so every element matches the family's tokens; bundle is tiny
|
|
36
|
+
* (~10KB inline data, no third-party UI dependency).
|
|
37
|
+
*/
|
|
38
|
+
export const EmojiPalette = ({
|
|
39
|
+
onSelect,
|
|
40
|
+
categories = DEFAULT_EMOJI_CATEGORIES,
|
|
41
|
+
columns = 9,
|
|
42
|
+
searchPlaceholder = "Search emojis…",
|
|
43
|
+
className,
|
|
44
|
+
style,
|
|
45
|
+
}: EmojiPaletteProps) => {
|
|
46
|
+
const [query, setQuery] = useState("");
|
|
47
|
+
const [focusedChar, setFocusedChar] = useState<string | null>(null);
|
|
48
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
|
|
50
|
+
const searchResults = useMemo(
|
|
51
|
+
() => searchEmojis(query, categories),
|
|
52
|
+
[query, categories],
|
|
53
|
+
);
|
|
54
|
+
const isSearching = query.trim().length > 0;
|
|
55
|
+
|
|
56
|
+
// Build a flat list of characters (in display order) for keyboard nav.
|
|
57
|
+
const flatChars: string[] = useMemo(() => {
|
|
58
|
+
if (isSearching) return searchResults.map((e) => e.char);
|
|
59
|
+
return categories.flatMap((c) => c.emojis.map((e) => e.char));
|
|
60
|
+
}, [isSearching, searchResults, categories]);
|
|
61
|
+
|
|
62
|
+
const focusEmoji = useCallback((char: string) => {
|
|
63
|
+
setFocusedChar(char);
|
|
64
|
+
const el = gridRef.current?.querySelector<HTMLButtonElement>(
|
|
65
|
+
`[data-char="${CSS.escape(char)}"]`,
|
|
66
|
+
);
|
|
67
|
+
el?.focus();
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
71
|
+
if (flatChars.length === 0 || !focusedChar) return;
|
|
72
|
+
const idx = flatChars.indexOf(focusedChar);
|
|
73
|
+
if (idx === -1) return;
|
|
74
|
+
let next = idx;
|
|
75
|
+
switch (e.key) {
|
|
76
|
+
case "ArrowRight":
|
|
77
|
+
next = Math.min(idx + 1, flatChars.length - 1);
|
|
78
|
+
break;
|
|
79
|
+
case "ArrowLeft":
|
|
80
|
+
next = Math.max(idx - 1, 0);
|
|
81
|
+
break;
|
|
82
|
+
case "ArrowDown":
|
|
83
|
+
next = Math.min(idx + columns, flatChars.length - 1);
|
|
84
|
+
break;
|
|
85
|
+
case "ArrowUp":
|
|
86
|
+
next = Math.max(idx - columns, 0);
|
|
87
|
+
break;
|
|
88
|
+
case "Home":
|
|
89
|
+
next = 0;
|
|
90
|
+
break;
|
|
91
|
+
case "End":
|
|
92
|
+
next = flatChars.length - 1;
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
if (next !== idx) focusEmoji(flatChars[next]);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const renderEmoji = (entry: EmojiEntry) => (
|
|
102
|
+
<button
|
|
103
|
+
key={entry.char}
|
|
104
|
+
type="button"
|
|
105
|
+
data-char={entry.char}
|
|
106
|
+
className={s.emoji}
|
|
107
|
+
title={entry.name}
|
|
108
|
+
aria-label={entry.name}
|
|
109
|
+
tabIndex={focusedChar === entry.char ? 0 : -1}
|
|
110
|
+
onClick={() => onSelect(entry.char)}
|
|
111
|
+
onFocus={() => setFocusedChar(entry.char)}
|
|
112
|
+
>
|
|
113
|
+
{entry.char}
|
|
114
|
+
</button>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Initialize keyboard focus on the first emoji in view.
|
|
118
|
+
const firstChar = flatChars[0];
|
|
119
|
+
if (focusedChar === null && firstChar) {
|
|
120
|
+
setFocusedChar(firstChar);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const gridStyle: CSSProperties = {
|
|
124
|
+
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className={clsx(s.root, className)} style={style}>
|
|
129
|
+
<div className={s.search_wrap}>
|
|
130
|
+
<Input
|
|
131
|
+
type="search"
|
|
132
|
+
placeholder={searchPlaceholder}
|
|
133
|
+
value={query}
|
|
134
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
135
|
+
showClear
|
|
136
|
+
aria-label="Search emojis"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div
|
|
141
|
+
className={s.viewport}
|
|
142
|
+
ref={gridRef}
|
|
143
|
+
onKeyDown={handleKeyDown}
|
|
144
|
+
role="grid"
|
|
145
|
+
aria-label={isSearching ? "Search results" : "Emojis"}
|
|
146
|
+
>
|
|
147
|
+
{isSearching ? (
|
|
148
|
+
searchResults.length > 0 ? (
|
|
149
|
+
<div className={s.grid} style={gridStyle}>
|
|
150
|
+
{searchResults.map(renderEmoji)}
|
|
151
|
+
</div>
|
|
152
|
+
) : (
|
|
153
|
+
<div className={s.empty}>No results</div>
|
|
154
|
+
)
|
|
155
|
+
) : (
|
|
156
|
+
categories.map((cat) => (
|
|
157
|
+
<section key={cat.id} className={s.category}>
|
|
158
|
+
<h3 className={s.category_header}>{cat.label}</h3>
|
|
159
|
+
<div className={s.grid} style={gridStyle}>
|
|
160
|
+
{cat.emojis.map(renderEmoji)}
|
|
161
|
+
</div>
|
|
162
|
+
</section>
|
|
163
|
+
))
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { IconMoodSmile } from "@tabler/icons-react";
|
|
4
|
+
import {
|
|
5
|
+
Popover,
|
|
6
|
+
PopoverMenu,
|
|
7
|
+
PopoverTrigger,
|
|
8
|
+
} from "../../../primitives/popover/popover";
|
|
9
|
+
import { Icon } from "../../../primitives/icon/icon";
|
|
10
|
+
import { Button } from "../../button/button";
|
|
11
|
+
import { EmojiPalette } from "./emoji-palette";
|
|
12
|
+
import sPickerCommon from "../common/picker-popover-common.module.css";
|
|
13
|
+
import s from "./emoji-picker.module.css";
|
|
14
|
+
import type { Align, Position } from "../../../primitives/popover/popover";
|
|
15
|
+
|
|
16
|
+
export type EmojiPickerProps = {
|
|
17
|
+
/** Fires with the picked emoji character whenever one is chosen. */
|
|
18
|
+
onSelect: (emoji: string) => void;
|
|
19
|
+
/** Custom trigger icon. Defaults to a smile icon. */
|
|
20
|
+
icon?: ReactNode;
|
|
21
|
+
/** Number of grid columns in the palette. Default: 9. */
|
|
22
|
+
columns?: number;
|
|
23
|
+
/** Popover placement. */
|
|
24
|
+
popoverPosition?: Position;
|
|
25
|
+
popoverAlign?: Align;
|
|
26
|
+
/**
|
|
27
|
+
* Show the popover footer with a Done action so users can pick multiple
|
|
28
|
+
* emojis before dismissing. Default: true. Set to `false` to hide the
|
|
29
|
+
* footer - the picker still stays open after each selection so the
|
|
30
|
+
* consumer can close it manually via outside-click.
|
|
31
|
+
*/
|
|
32
|
+
showActionFooter?: boolean;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
className?: string;
|
|
35
|
+
style?: CSSProperties;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compact icon button that opens a popover containing `<EmojiPalette />`.
|
|
40
|
+
* Designed for "insert emoji" flows next to text inputs / textareas - not
|
|
41
|
+
* itself a form field. Use `<EmojiPalette />` directly when you need an
|
|
42
|
+
* inline picker without a trigger.
|
|
43
|
+
*
|
|
44
|
+
* The picker stays open after each selection so users can pick multiple
|
|
45
|
+
* emojis in one session; close via the footer Done button or by clicking
|
|
46
|
+
* outside.
|
|
47
|
+
*/
|
|
48
|
+
export const EmojiPicker = ({
|
|
49
|
+
onSelect,
|
|
50
|
+
icon,
|
|
51
|
+
columns = 9,
|
|
52
|
+
popoverPosition = "bottom",
|
|
53
|
+
popoverAlign = "end",
|
|
54
|
+
showActionFooter = true,
|
|
55
|
+
disabled,
|
|
56
|
+
className,
|
|
57
|
+
style,
|
|
58
|
+
}: EmojiPickerProps) => {
|
|
59
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Popover
|
|
63
|
+
position={popoverPosition}
|
|
64
|
+
align={popoverAlign}
|
|
65
|
+
isOpen={isOpen}
|
|
66
|
+
setIsOpen={setIsOpen}
|
|
67
|
+
closeOnClickOutside
|
|
68
|
+
className={clsx(sPickerCommon.picker_popover, className)}
|
|
69
|
+
style={style}
|
|
70
|
+
>
|
|
71
|
+
<PopoverTrigger asChild disabled={disabled}>
|
|
72
|
+
{icon ?? (
|
|
73
|
+
<Icon aria-hidden="true" clickable>
|
|
74
|
+
<IconMoodSmile />
|
|
75
|
+
</Icon>
|
|
76
|
+
)}
|
|
77
|
+
</PopoverTrigger>
|
|
78
|
+
|
|
79
|
+
{!disabled && (
|
|
80
|
+
<PopoverMenu
|
|
81
|
+
className={clsx(sPickerCommon.picker_popover_menu_base, s.menu)}
|
|
82
|
+
matchTriggerWidth={false}
|
|
83
|
+
>
|
|
84
|
+
<EmojiPalette columns={columns} onSelect={onSelect} />
|
|
85
|
+
|
|
86
|
+
{showActionFooter && (
|
|
87
|
+
<div className={sPickerCommon.picker_actions_footer}>
|
|
88
|
+
<Button
|
|
89
|
+
type="button"
|
|
90
|
+
variant="primary"
|
|
91
|
+
size="sm"
|
|
92
|
+
onClick={() => setIsOpen(false)}
|
|
93
|
+
>
|
|
94
|
+
Done
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</PopoverMenu>
|
|
99
|
+
)}
|
|
100
|
+
</Popover>
|
|
101
|
+
);
|
|
102
|
+
};
|