@atlure/ui 0.4.0 → 0.6.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.
- package/README.md +39 -6
- package/package.json +5 -6
- package/src/components/alert-dialog/alert-dialog.tsx +59 -0
- package/src/components/alert-dialog/utils.ts +5 -0
- package/src/components/avatar/avatar-group.tsx +52 -0
- package/src/components/avatar/avatar.tsx +54 -11
- package/src/components/badge/urgency-badge.tsx +21 -0
- package/src/components/calendar/calendar.tsx +189 -0
- package/src/components/checkbox/checkbox.tsx +26 -9
- package/src/components/chip/chip.tsx +66 -0
- package/src/components/date-range-picker/date-range-picker.tsx +104 -0
- package/src/components/dialog/dialog.tsx +137 -0
- package/src/components/format/date-label.tsx +41 -0
- package/src/components/format/distance-label.tsx +13 -0
- package/src/components/format/duration-label.tsx +13 -0
- package/src/components/format/money-label.tsx +20 -0
- package/src/components/picker/picker.tsx +104 -0
- package/src/components/progress/progress.tsx +95 -0
- package/src/components/progress/utils.ts +10 -0
- package/src/components/radio-group/radio-group-context.tsx +27 -0
- package/src/components/radio-group/radio-group.tsx +150 -0
- package/src/components/screen-header/screen-header.tsx +62 -0
- package/src/components/segmented-control/segmented-control.tsx +67 -0
- package/src/components/select/select.tsx +134 -0
- package/src/components/settings-row/settings-row.tsx +52 -0
- package/src/components/sheet/sheet.tsx +139 -0
- package/src/components/sheet/utils.ts +55 -0
- package/src/components/slider/format-label.ts +3 -0
- package/src/components/slider/hooks/use-slider-drag.ts +47 -0
- package/src/components/slider/range-slider.tsx +203 -0
- package/src/components/slider/slider.tsx +148 -0
- package/src/components/slider/utils.ts +57 -0
- package/src/components/star-rating/star-rating.tsx +98 -0
- package/src/components/switch/switch.tsx +34 -6
- package/src/components/tabs/tabs-context.tsx +32 -0
- package/src/components/tabs/tabs.tsx +239 -0
- package/src/components/time-picker/time-picker.tsx +131 -0
- package/src/components/toast/toast-context.tsx +26 -0
- package/src/components/toast/toast-provider.tsx +70 -0
- package/src/components/toast/toast.tsx +41 -0
- package/src/components/toast/utils.ts +2 -0
- package/src/index.ts +54 -0
- package/src/lib/calendar.ts +204 -0
- package/src/lib/format/date.ts +46 -0
- package/src/lib/format/distance.ts +36 -0
- package/src/lib/format/duration.ts +28 -0
- package/src/lib/format/money.ts +20 -0
- package/src/lib/locale.tsx +36 -0
- package/src/lib/portal.tsx +54 -0
- package/src/lib/touch-target.ts +5 -1
- package/src/lib/use-sheet.ts +18 -0
- package/src/lib/use-toast.ts +17 -0
- package/src/variants/avatar-variants.ts +68 -15
- package/src/variants/badge-variants.ts +8 -0
- package/src/variants/calendar-variants.ts +26 -0
- package/src/variants/checkbox-variants.ts +5 -14
- package/src/variants/chip-variants.ts +38 -0
- package/src/variants/dialog-variants.ts +11 -0
- package/src/variants/index.ts +9 -0
- package/src/variants/overlay-variants.ts +1 -0
- package/src/variants/progress-variants.ts +18 -0
- package/src/variants/radio-group-variants.ts +48 -0
- package/src/variants/screen-header-variants.ts +33 -0
- package/src/variants/segmented-control-variants.ts +41 -0
- package/src/variants/select-variants.ts +48 -0
- package/src/variants/settings-row-variants.ts +18 -0
- package/src/variants/sheet-variants.ts +10 -0
- package/src/variants/slider-variants.ts +8 -0
- package/src/variants/star-rating-variants.ts +28 -0
- package/src/variants/switch-variants.ts +26 -4
- package/src/variants/tabs-variants.ts +46 -0
- package/src/variants/toast-variants.ts +31 -0
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ export function SitterCard({ sitter, onOpen, openLabel }) {
|
|
|
42
42
|
return (
|
|
43
43
|
<Card onPress={onOpen} accessibilityLabel={openLabel}>
|
|
44
44
|
<CardContent className="flex-row items-center gap-md">
|
|
45
|
-
<Avatar name={sitter.displayName}
|
|
45
|
+
<Avatar name={sitter.displayName} src={sitter.avatarUrl} presence="online" />
|
|
46
46
|
<Text variant="title">{sitter.displayName}</Text>
|
|
47
47
|
</CardContent>
|
|
48
48
|
</Card>
|
|
@@ -51,9 +51,42 @@ export function SitterCard({ sitter, onOpen, openLabel }) {
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
Components: `Text`, `Button`, `Card` (`CardHeader`, `CardContent`, `CardFooter`), `Input`, `Label`,
|
|
54
|
-
`Badge`, `Avatar
|
|
54
|
+
`Badge`, `Avatar` (`AvatarGroup`), `Separator`, `Skeleton`, `Switch`, `Checkbox`, `Sheet`, `Select` (`SelectItem`),
|
|
55
|
+
`Picker`, `Dialog`, `AlertDialog`, `Toast`, `MoneyLabel`, `DistanceLabel`, `DurationLabel`, `DateLabel`, `DateRangeLabel`.
|
|
56
|
+
|
|
57
|
+
Overlays queue through a host, so the app root mounts it once, above the navigator:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
<PortalHost>
|
|
61
|
+
<ToastProvider bottomInset={insets.bottom}>
|
|
62
|
+
<Navigator />
|
|
63
|
+
</ToastProvider>
|
|
64
|
+
</PortalHost>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`Dialog`, `AlertDialog` and `Toast` throw a named error if that host is missing, rather than
|
|
68
|
+
rendering nothing and leaving you to guess why.
|
|
69
|
+
|
|
70
|
+
`Avatar` accepts the image as either `src` or the older `uri`; `src` wins when both are set. `uri` is
|
|
71
|
+
kept only so 0.3.0 consumers do not break, and goes away before 1.0.
|
|
55
72
|
|
|
56
73
|
Every user-facing string is a prop. This package contains no display copy, so i18n stays in the app.
|
|
74
|
+
The one exception is `MoneyLabel`'s `per` suffix, which renders the English words `per hour`, `per
|
|
75
|
+
night` or `per walk`; a message catalogue is out of scope for v1, so this is the single string that
|
|
76
|
+
must be revisited when translation lands.
|
|
77
|
+
|
|
78
|
+
## Locale and number formatting
|
|
79
|
+
|
|
80
|
+
`LocaleProvider` carries a BCP-47 locale and a measurement system (`metric` by default, `imperial`
|
|
81
|
+
opt-in); `useLocale()` reads it. Without a provider the defaults are `en-IE` and `metric`.
|
|
82
|
+
|
|
83
|
+
Every unit, currency symbol, separator and date order comes from `Intl`, never from a literal — so
|
|
84
|
+
a price renders in its own currency regardless of the reader's locale, and `1 hr 30 mins` becomes
|
|
85
|
+
`1 Std. 30 Min.` in `de-DE`. Money carries minor units, and the number of decimal places is read back
|
|
86
|
+
from `Intl` for that currency rather than assumed to be two.
|
|
87
|
+
|
|
88
|
+
This requires `Intl` to be present. Hermes needs it switched on explicitly in the app's
|
|
89
|
+
`app.config.ts`; that change belongs to the app, not to this package.
|
|
57
90
|
|
|
58
91
|
## Variant recipes are shared, render layers are not
|
|
59
92
|
|
|
@@ -105,10 +138,10 @@ changing how it looks.
|
|
|
105
138
|
`pnpm test` runs Vitest with React Testing Library. `react-native` is aliased to `react-native-web`
|
|
106
139
|
so components render into jsdom and can be queried by role and accessible name.
|
|
107
140
|
|
|
108
|
-
`@atlure/tokens`
|
|
109
|
-
`tsconfig.json` and
|
|
110
|
-
|
|
111
|
-
been built first, and can never read a stale `dist`.
|
|
141
|
+
`@atlure/tokens` and `@atlure/types` are resolved to `../tokens/src` and `../types/src` for
|
|
142
|
+
typechecking and testing, via `paths` in `tsconfig.json` and aliases in `vitest.config.ts`. Published
|
|
143
|
+
consumers still get `dist` through those packages' `exports`; resolving to source here means this
|
|
144
|
+
package's checks do not depend on either having been built first, and can never read a stale `dist`.
|
|
112
145
|
|
|
113
146
|
What that proves: press handling, disabled and loading behaviour, controlled toggle state, change
|
|
114
147
|
propagation, accessible roles and names, and the barrel generator's output.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlure/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Atlure React Native component library: NativeWind primitives built on @atlure/tokens, shipped as untranspiled TypeScript source",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "David Moreira",
|
|
@@ -37,22 +37,21 @@
|
|
|
37
37
|
"class-variance-authority": "0.7.1",
|
|
38
38
|
"clsx": "2.1.1",
|
|
39
39
|
"tailwind-merge": "2.6.0",
|
|
40
|
-
"@atlure/icons": "0.
|
|
41
|
-
"@atlure/tokens": "0.
|
|
40
|
+
"@atlure/icons": "0.6.0",
|
|
41
|
+
"@atlure/tokens": "0.6.0",
|
|
42
|
+
"@atlure/types": "0.6.0"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
45
|
"nativewind": "^4.2.6",
|
|
45
46
|
"react": ">=18.3.0",
|
|
46
47
|
"react-native": ">=0.81.0",
|
|
48
|
+
"react-native-gesture-handler": ">=2.16.0",
|
|
47
49
|
"react-native-reanimated": ">=3.16.0",
|
|
48
50
|
"react-native-safe-area-context": ">=4.10.0",
|
|
49
51
|
"react-native-svg": ">=15.0.0",
|
|
50
52
|
"tailwindcss": "^3.4.19"
|
|
51
53
|
},
|
|
52
54
|
"peerDependenciesMeta": {
|
|
53
|
-
"react-native-reanimated": {
|
|
54
|
-
"optional": true
|
|
55
|
-
},
|
|
56
55
|
"react-native-safe-area-context": {
|
|
57
56
|
"optional": true
|
|
58
57
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Button } from "../button/button";
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogDescription,
|
|
5
|
+
DialogFooter,
|
|
6
|
+
DialogHeader,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
} from "../dialog/dialog";
|
|
9
|
+
import { alertDialogConfirmVariant } from "./utils";
|
|
10
|
+
|
|
11
|
+
export interface AlertDialogProps {
|
|
12
|
+
isOpen: boolean;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
confirmLabel: string;
|
|
16
|
+
cancelLabel: string;
|
|
17
|
+
onConfirm: () => void;
|
|
18
|
+
onCancel: () => void;
|
|
19
|
+
isDestructive?: boolean;
|
|
20
|
+
isConfirmLoading?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AlertDialog({
|
|
25
|
+
isOpen,
|
|
26
|
+
title,
|
|
27
|
+
description,
|
|
28
|
+
confirmLabel,
|
|
29
|
+
cancelLabel,
|
|
30
|
+
onConfirm,
|
|
31
|
+
onCancel,
|
|
32
|
+
isDestructive = false,
|
|
33
|
+
isConfirmLoading = false,
|
|
34
|
+
className,
|
|
35
|
+
}: AlertDialogProps) {
|
|
36
|
+
return (
|
|
37
|
+
<Dialog
|
|
38
|
+
isOpen={isOpen}
|
|
39
|
+
onClose={onCancel}
|
|
40
|
+
isDismissible={false}
|
|
41
|
+
accessibilityLabel={title}
|
|
42
|
+
className={className}
|
|
43
|
+
>
|
|
44
|
+
<DialogHeader>
|
|
45
|
+
<DialogTitle>{title}</DialogTitle>
|
|
46
|
+
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
|
47
|
+
</DialogHeader>
|
|
48
|
+
<DialogFooter>
|
|
49
|
+
<Button label={cancelLabel} variant="outline" onPress={onCancel} />
|
|
50
|
+
<Button
|
|
51
|
+
label={confirmLabel}
|
|
52
|
+
variant={alertDialogConfirmVariant(isDestructive)}
|
|
53
|
+
isLoading={isConfirmLoading}
|
|
54
|
+
onPress={onConfirm}
|
|
55
|
+
/>
|
|
56
|
+
</DialogFooter>
|
|
57
|
+
</Dialog>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Children, type ReactNode } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import {
|
|
6
|
+
avatarFallbackVariants,
|
|
7
|
+
avatarGroupItemVariants,
|
|
8
|
+
avatarGroupVariants,
|
|
9
|
+
avatarVariants,
|
|
10
|
+
type AvatarVariantProps,
|
|
11
|
+
} from "../../variants/avatar-variants";
|
|
12
|
+
import { Text } from "../text/text";
|
|
13
|
+
|
|
14
|
+
export interface AvatarGroupProps extends Omit<ViewProps, "children"> {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
max?: number;
|
|
17
|
+
size?: NonNullable<AvatarVariantProps["size"]>;
|
|
18
|
+
overflowAccessibilityLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AvatarGroup({
|
|
22
|
+
children,
|
|
23
|
+
max = 4,
|
|
24
|
+
size = "md",
|
|
25
|
+
overflowAccessibilityLabel,
|
|
26
|
+
className,
|
|
27
|
+
...viewProps
|
|
28
|
+
}: AvatarGroupProps) {
|
|
29
|
+
const avatars = Children.toArray(children);
|
|
30
|
+
const visibleAvatars = avatars.slice(0, max);
|
|
31
|
+
const overflowCount = avatars.length - visibleAvatars.length;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<View accessibilityRole="list" className={cn(avatarGroupVariants(), className)} {...viewProps}>
|
|
35
|
+
{visibleAvatars.map((avatar, index) => (
|
|
36
|
+
<View key={index} className={avatarGroupItemVariants({ size, isFirst: index === 0 })}>
|
|
37
|
+
{avatar}
|
|
38
|
+
</View>
|
|
39
|
+
))}
|
|
40
|
+
{overflowCount > 0 ? (
|
|
41
|
+
<View
|
|
42
|
+
accessibilityLabel={overflowAccessibilityLabel ?? `+${overflowCount}`}
|
|
43
|
+
className={avatarGroupItemVariants({ size, isFirst: false })}
|
|
44
|
+
>
|
|
45
|
+
<View className={avatarVariants({ size })}>
|
|
46
|
+
<Text className={avatarFallbackVariants({ size })}>{`+${overflowCount}`}</Text>
|
|
47
|
+
</View>
|
|
48
|
+
</View>
|
|
49
|
+
) : null}
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -1,42 +1,85 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import { Image, View, type ViewProps } from "react-native";
|
|
2
3
|
|
|
3
4
|
import { cn } from "../../lib/cn";
|
|
4
5
|
import {
|
|
5
6
|
avatarFallbackVariants,
|
|
7
|
+
avatarPresenceVariants,
|
|
8
|
+
avatarRootVariants,
|
|
6
9
|
avatarVariants,
|
|
7
10
|
type AvatarVariantProps,
|
|
8
11
|
} from "../../variants/avatar-variants";
|
|
12
|
+
import { Skeleton } from "../skeleton/skeleton";
|
|
9
13
|
import { Text } from "../text/text";
|
|
10
14
|
import { toInitials } from "./utils";
|
|
11
15
|
|
|
16
|
+
export type AvatarPresence = "online" | "offline" | "none";
|
|
17
|
+
|
|
12
18
|
export interface AvatarProps extends ViewProps {
|
|
13
19
|
name: string;
|
|
20
|
+
src?: string | null;
|
|
14
21
|
uri?: string | null;
|
|
15
22
|
size?: NonNullable<AvatarVariantProps["size"]>;
|
|
23
|
+
shape?: NonNullable<AvatarVariantProps["shape"]>;
|
|
24
|
+
presence?: AvatarPresence;
|
|
25
|
+
presenceAccessibilityLabel?: string;
|
|
26
|
+
loadingAccessibilityLabel?: string;
|
|
16
27
|
hasRing?: boolean;
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export function Avatar({
|
|
20
31
|
name,
|
|
32
|
+
src = null,
|
|
21
33
|
uri = null,
|
|
22
34
|
size = "md",
|
|
35
|
+
shape = "circle",
|
|
36
|
+
presence = "none",
|
|
37
|
+
presenceAccessibilityLabel,
|
|
38
|
+
loadingAccessibilityLabel,
|
|
23
39
|
hasRing = false,
|
|
24
40
|
className,
|
|
25
41
|
...viewProps
|
|
26
42
|
}: AvatarProps) {
|
|
27
|
-
const
|
|
43
|
+
const [failedUri, setFailedUri] = useState<string | null>(null);
|
|
44
|
+
const [loadedUri, setLoadedUri] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
const requestedUri = src ?? uri;
|
|
47
|
+
const imageUri = requestedUri && requestedUri !== failedUri ? requestedUri : null;
|
|
48
|
+
const isLoading = imageUri !== null && imageUri !== loadedUri;
|
|
28
49
|
|
|
29
50
|
return (
|
|
30
|
-
<View
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
<View className={avatarRootVariants()} {...viewProps}>
|
|
52
|
+
<View
|
|
53
|
+
accessibilityRole={imageUri ? undefined : "image"}
|
|
54
|
+
accessibilityLabel={imageUri ? undefined : name}
|
|
55
|
+
className={cn(avatarVariants({ size, shape, hasRing }), className)}
|
|
56
|
+
>
|
|
57
|
+
{imageUri ? (
|
|
58
|
+
<>
|
|
59
|
+
<Image
|
|
60
|
+
source={{ uri: imageUri }}
|
|
61
|
+
accessibilityLabel={name}
|
|
62
|
+
className="h-full w-full"
|
|
63
|
+
onLoadEnd={() => setLoadedUri(imageUri)}
|
|
64
|
+
onError={() => setFailedUri(imageUri)}
|
|
65
|
+
/>
|
|
66
|
+
{isLoading ? (
|
|
67
|
+
<Skeleton
|
|
68
|
+
shape="circle"
|
|
69
|
+
accessibilityLabel={loadingAccessibilityLabel ?? name}
|
|
70
|
+
className="absolute inset-0 h-full w-full"
|
|
71
|
+
/>
|
|
72
|
+
) : null}
|
|
73
|
+
</>
|
|
74
|
+
) : (
|
|
75
|
+
<Text className={avatarFallbackVariants({ size })}>{toInitials(name)}</Text>
|
|
76
|
+
)}
|
|
77
|
+
</View>
|
|
78
|
+
{presence === "none" ? null : (
|
|
79
|
+
<View
|
|
80
|
+
accessibilityLabel={presenceAccessibilityLabel ?? `${name} ${presence}`}
|
|
81
|
+
className={avatarPresenceVariants({ size, presence })}
|
|
82
|
+
/>
|
|
40
83
|
)}
|
|
41
84
|
</View>
|
|
42
85
|
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Urgency } from "@atlure/types";
|
|
2
|
+
|
|
3
|
+
import type { BadgeVariantProps } from "../../variants/badge-variants";
|
|
4
|
+
import { Badge, type BadgeProps } from "./badge";
|
|
5
|
+
|
|
6
|
+
type BadgeVariant = NonNullable<BadgeVariantProps["variant"]>;
|
|
7
|
+
|
|
8
|
+
export const urgencyBadgeVariant: Record<Urgency, BadgeVariant> = {
|
|
9
|
+
low: "secondary",
|
|
10
|
+
medium: "warning",
|
|
11
|
+
high: "destructive",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface UrgencyBadgeProps extends Omit<BadgeProps, "label" | "variant"> {
|
|
15
|
+
urgency: Urgency;
|
|
16
|
+
label: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function UrgencyBadge({ urgency, ...badgeProps }: UrgencyBadgeProps) {
|
|
20
|
+
return <Badge variant={urgencyBadgeVariant[urgency]} {...badgeProps} />;
|
|
21
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight, iconSize } from "@atlure/icons";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { Pressable, View } from "react-native";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
addMonths,
|
|
7
|
+
buildMonthGrid,
|
|
8
|
+
firstDayOfWeek,
|
|
9
|
+
formatFullDate,
|
|
10
|
+
formatMonthLabel,
|
|
11
|
+
formatWeekdayShort,
|
|
12
|
+
isDateInRange,
|
|
13
|
+
} from "../../lib/calendar";
|
|
14
|
+
import { cn } from "../../lib/cn";
|
|
15
|
+
import { useLocale } from "../../lib/locale";
|
|
16
|
+
import {
|
|
17
|
+
calendarContainerClassName,
|
|
18
|
+
calendarDayCellClassName,
|
|
19
|
+
calendarDayCellDisabledClassName,
|
|
20
|
+
calendarDayCellInRangeClassName,
|
|
21
|
+
calendarDayCellRangeEndClassName,
|
|
22
|
+
calendarDayCellRangeStartClassName,
|
|
23
|
+
calendarDayCellSelectedClassName,
|
|
24
|
+
calendarDayLabelClassName,
|
|
25
|
+
calendarDayLabelOutsideMonthClassName,
|
|
26
|
+
calendarDayLabelSelectedClassName,
|
|
27
|
+
calendarHeaderClassName,
|
|
28
|
+
calendarMarkerDotClassName,
|
|
29
|
+
calendarMonthLabelClassName,
|
|
30
|
+
calendarNavButtonClassName,
|
|
31
|
+
calendarWeekRowClassName,
|
|
32
|
+
calendarWeekdayCellClassName,
|
|
33
|
+
calendarWeekdayLabelClassName,
|
|
34
|
+
calendarWeekdayRowClassName,
|
|
35
|
+
} from "../../variants/calendar-variants";
|
|
36
|
+
import { Text } from "../text/text";
|
|
37
|
+
|
|
38
|
+
export interface CalendarHighlight {
|
|
39
|
+
start?: string;
|
|
40
|
+
end?: string;
|
|
41
|
+
inProgressStart?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CalendarProps {
|
|
45
|
+
yearMonth: string;
|
|
46
|
+
selected?: string;
|
|
47
|
+
onSelect?: (iso: string) => void;
|
|
48
|
+
onMonthChange?: (yearMonth: string) => void;
|
|
49
|
+
disabledDates?: (iso: string) => boolean;
|
|
50
|
+
minDate?: string;
|
|
51
|
+
maxDate?: string;
|
|
52
|
+
markers?: readonly string[];
|
|
53
|
+
highlight?: CalendarHighlight;
|
|
54
|
+
accessibilityLabel?: string;
|
|
55
|
+
previousMonthAccessibilityLabel?: string;
|
|
56
|
+
nextMonthAccessibilityLabel?: string;
|
|
57
|
+
className?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Calendar({
|
|
61
|
+
yearMonth,
|
|
62
|
+
selected,
|
|
63
|
+
onSelect,
|
|
64
|
+
onMonthChange,
|
|
65
|
+
disabledDates,
|
|
66
|
+
minDate,
|
|
67
|
+
maxDate,
|
|
68
|
+
markers,
|
|
69
|
+
highlight,
|
|
70
|
+
accessibilityLabel,
|
|
71
|
+
previousMonthAccessibilityLabel = "Previous month",
|
|
72
|
+
nextMonthAccessibilityLabel = "Next month",
|
|
73
|
+
className,
|
|
74
|
+
}: CalendarProps) {
|
|
75
|
+
const { locale } = useLocale();
|
|
76
|
+
const [displayYearMonth, setDisplayYearMonth] = useState(yearMonth);
|
|
77
|
+
|
|
78
|
+
useEffect(() => setDisplayYearMonth(yearMonth), [yearMonth]);
|
|
79
|
+
|
|
80
|
+
const firstDow = useMemo(() => firstDayOfWeek(locale), [locale]);
|
|
81
|
+
const weeks = useMemo(
|
|
82
|
+
() => buildMonthGrid(displayYearMonth, firstDow),
|
|
83
|
+
[displayYearMonth, firstDow],
|
|
84
|
+
);
|
|
85
|
+
const weekdayLabels = useMemo(
|
|
86
|
+
() => Array.from({ length: 7 }, (_, i) => formatWeekdayShort((firstDow + i) % 7, locale)),
|
|
87
|
+
[firstDow, locale],
|
|
88
|
+
);
|
|
89
|
+
const markerSet = useMemo(() => new Set(markers ?? []), [markers]);
|
|
90
|
+
|
|
91
|
+
const changeMonth = (delta: number) => {
|
|
92
|
+
const next = addMonths(displayYearMonth, delta);
|
|
93
|
+
setDisplayYearMonth(next);
|
|
94
|
+
onMonthChange?.(next);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<View
|
|
99
|
+
accessibilityLabel={accessibilityLabel}
|
|
100
|
+
className={cn(calendarContainerClassName, className)}
|
|
101
|
+
>
|
|
102
|
+
<View className={calendarHeaderClassName}>
|
|
103
|
+
<Pressable
|
|
104
|
+
accessibilityRole="button"
|
|
105
|
+
accessibilityLabel={previousMonthAccessibilityLabel}
|
|
106
|
+
className={calendarNavButtonClassName}
|
|
107
|
+
onPress={() => changeMonth(-1)}
|
|
108
|
+
>
|
|
109
|
+
<ChevronLeft size={iconSize.md} />
|
|
110
|
+
</Pressable>
|
|
111
|
+
<Text className={calendarMonthLabelClassName}>
|
|
112
|
+
{formatMonthLabel(displayYearMonth, locale)}
|
|
113
|
+
</Text>
|
|
114
|
+
<Pressable
|
|
115
|
+
accessibilityRole="button"
|
|
116
|
+
accessibilityLabel={nextMonthAccessibilityLabel}
|
|
117
|
+
className={calendarNavButtonClassName}
|
|
118
|
+
onPress={() => changeMonth(1)}
|
|
119
|
+
>
|
|
120
|
+
<ChevronRight size={iconSize.md} />
|
|
121
|
+
</Pressable>
|
|
122
|
+
</View>
|
|
123
|
+
|
|
124
|
+
<View className={calendarWeekdayRowClassName}>
|
|
125
|
+
{weekdayLabels.map((label, weekdayIndex) => (
|
|
126
|
+
<View key={weekdayIndex} className={calendarWeekdayCellClassName}>
|
|
127
|
+
<Text className={calendarWeekdayLabelClassName}>{label}</Text>
|
|
128
|
+
</View>
|
|
129
|
+
))}
|
|
130
|
+
</View>
|
|
131
|
+
|
|
132
|
+
{weeks.map((week, weekIndex) => (
|
|
133
|
+
<View key={weekIndex} className={calendarWeekRowClassName}>
|
|
134
|
+
{week.map((cell) => {
|
|
135
|
+
const isDisabled =
|
|
136
|
+
disabledDates?.(cell.iso) === true || !isDateInRange(cell.iso, minDate, maxDate);
|
|
137
|
+
const isSelected = selected === cell.iso;
|
|
138
|
+
const isRangeStart = highlight?.start === cell.iso;
|
|
139
|
+
const isRangeEnd = highlight?.end === cell.iso;
|
|
140
|
+
const isInProgressStart = highlight?.inProgressStart === cell.iso;
|
|
141
|
+
const isInRange =
|
|
142
|
+
highlight?.start !== undefined &&
|
|
143
|
+
highlight.end !== undefined &&
|
|
144
|
+
cell.iso > highlight.start &&
|
|
145
|
+
cell.iso < highlight.end;
|
|
146
|
+
const isHighlighted =
|
|
147
|
+
isSelected || isRangeStart || isRangeEnd || isInProgressStart;
|
|
148
|
+
const hasMarker = markerSet.has(cell.iso);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Pressable
|
|
152
|
+
key={cell.iso}
|
|
153
|
+
accessibilityRole="button"
|
|
154
|
+
accessibilityLabel={formatFullDate(cell.iso, locale)}
|
|
155
|
+
accessibilityState={{ disabled: isDisabled, selected: isHighlighted }}
|
|
156
|
+
aria-disabled={isDisabled}
|
|
157
|
+
aria-selected={isHighlighted}
|
|
158
|
+
disabled={isDisabled}
|
|
159
|
+
onPress={() => {
|
|
160
|
+
if (isDisabled) return;
|
|
161
|
+
onSelect?.(cell.iso);
|
|
162
|
+
}}
|
|
163
|
+
className={cn(
|
|
164
|
+
calendarDayCellClassName,
|
|
165
|
+
isInRange && calendarDayCellInRangeClassName,
|
|
166
|
+
isRangeStart && calendarDayCellRangeStartClassName,
|
|
167
|
+
isRangeEnd && calendarDayCellRangeEndClassName,
|
|
168
|
+
(isSelected || isInProgressStart) && calendarDayCellSelectedClassName,
|
|
169
|
+
isDisabled && calendarDayCellDisabledClassName,
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
<Text
|
|
173
|
+
className={cn(
|
|
174
|
+
calendarDayLabelClassName,
|
|
175
|
+
!cell.isCurrentMonth && calendarDayLabelOutsideMonthClassName,
|
|
176
|
+
isHighlighted && calendarDayLabelSelectedClassName,
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
{cell.dayNumber}
|
|
180
|
+
</Text>
|
|
181
|
+
{hasMarker && <View className={calendarMarkerDotClassName} />}
|
|
182
|
+
</Pressable>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</View>
|
|
186
|
+
))}
|
|
187
|
+
</View>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { Check, iconSize, Minus } from "@atlure/icons";
|
|
1
2
|
import { Pressable, type PressableProps, View } from "react-native";
|
|
2
3
|
|
|
3
4
|
import { cn } from "../../lib/cn";
|
|
4
|
-
import {
|
|
5
|
+
import { touchTargetHitSlopForSize } from "../../lib/touch-target";
|
|
5
6
|
import {
|
|
7
|
+
checkboxBoxSize,
|
|
6
8
|
checkboxBoxVariants,
|
|
7
|
-
|
|
9
|
+
checkboxIndicatorClassName,
|
|
8
10
|
checkboxRowVariants,
|
|
9
11
|
} from "../../variants/checkbox-variants";
|
|
10
12
|
import { Label } from "../label/label";
|
|
@@ -14,6 +16,7 @@ export interface CheckboxProps
|
|
|
14
16
|
isChecked: boolean;
|
|
15
17
|
onValueChange: (isChecked: boolean) => void;
|
|
16
18
|
label?: string;
|
|
19
|
+
isIndeterminate?: boolean;
|
|
17
20
|
isDisabled?: boolean;
|
|
18
21
|
isInvalid?: boolean;
|
|
19
22
|
}
|
|
@@ -22,29 +25,43 @@ export function Checkbox({
|
|
|
22
25
|
isChecked,
|
|
23
26
|
onValueChange,
|
|
24
27
|
label,
|
|
28
|
+
isIndeterminate = false,
|
|
25
29
|
isDisabled = false,
|
|
26
30
|
isInvalid = false,
|
|
27
31
|
accessibilityLabel,
|
|
28
32
|
className,
|
|
29
33
|
...pressableProps
|
|
30
34
|
}: CheckboxProps) {
|
|
35
|
+
const checkedState = isIndeterminate ? "mixed" : isChecked;
|
|
36
|
+
|
|
31
37
|
return (
|
|
32
38
|
<Pressable
|
|
33
39
|
accessibilityRole="checkbox"
|
|
34
40
|
accessibilityLabel={accessibilityLabel ?? label}
|
|
35
|
-
accessibilityState={{ checked:
|
|
36
|
-
aria-checked={
|
|
41
|
+
accessibilityState={{ checked: checkedState, disabled: isDisabled }}
|
|
42
|
+
aria-checked={checkedState}
|
|
37
43
|
aria-disabled={isDisabled}
|
|
38
44
|
disabled={isDisabled}
|
|
39
|
-
hitSlop={
|
|
40
|
-
onPress={() => onValueChange(!isChecked)}
|
|
45
|
+
hitSlop={touchTargetHitSlopForSize(checkboxBoxSize)}
|
|
46
|
+
onPress={() => onValueChange(isIndeterminate ? true : !isChecked)}
|
|
41
47
|
className={cn(checkboxRowVariants({ isDisabled }), className)}
|
|
42
48
|
{...pressableProps}
|
|
43
49
|
>
|
|
44
|
-
<View
|
|
45
|
-
|
|
50
|
+
<View
|
|
51
|
+
className={checkboxBoxVariants({ isSelected: isIndeterminate || isChecked, isInvalid })}
|
|
52
|
+
>
|
|
53
|
+
{isIndeterminate ? (
|
|
54
|
+
<Minus size={iconSize.sm} className={checkboxIndicatorClassName} />
|
|
55
|
+
) : null}
|
|
56
|
+
{!isIndeterminate && isChecked ? (
|
|
57
|
+
<Check size={iconSize.sm} className={checkboxIndicatorClassName} />
|
|
58
|
+
) : null}
|
|
46
59
|
</View>
|
|
47
|
-
{label ?
|
|
60
|
+
{label ? (
|
|
61
|
+
<Label isDisabled={isDisabled} isInvalid={isInvalid}>
|
|
62
|
+
{label}
|
|
63
|
+
</Label>
|
|
64
|
+
) : null}
|
|
48
65
|
</Pressable>
|
|
49
66
|
);
|
|
50
67
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { iconSize, X } from "@atlure/icons";
|
|
2
|
+
import { Pressable, type PressableProps, View, type ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { touchTargetHitSlopForSize } from "../../lib/touch-target";
|
|
6
|
+
import {
|
|
7
|
+
chipBodyClassName,
|
|
8
|
+
chipDismissClassName,
|
|
9
|
+
chipHeight,
|
|
10
|
+
chipLabelVariants,
|
|
11
|
+
chipVariants,
|
|
12
|
+
} from "../../variants/chip-variants";
|
|
13
|
+
import { Text } from "../text/text";
|
|
14
|
+
|
|
15
|
+
export interface ChipProps
|
|
16
|
+
extends Omit<PressableProps, "children" | "disabled" | "accessibilityState" | "style"> {
|
|
17
|
+
label: string;
|
|
18
|
+
isSelected?: boolean;
|
|
19
|
+
isDisabled?: boolean;
|
|
20
|
+
onDismiss?: () => void;
|
|
21
|
+
dismissAccessibilityLabel?: string;
|
|
22
|
+
containerClassName?: ViewProps["className"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Chip({
|
|
26
|
+
label,
|
|
27
|
+
isSelected = false,
|
|
28
|
+
isDisabled = false,
|
|
29
|
+
onDismiss,
|
|
30
|
+
dismissAccessibilityLabel,
|
|
31
|
+
accessibilityLabel,
|
|
32
|
+
className,
|
|
33
|
+
containerClassName,
|
|
34
|
+
...pressableProps
|
|
35
|
+
}: ChipProps) {
|
|
36
|
+
return (
|
|
37
|
+
<View className={cn(chipVariants({ isSelected, isDisabled }), containerClassName)}>
|
|
38
|
+
<Pressable
|
|
39
|
+
accessibilityRole="button"
|
|
40
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
41
|
+
accessibilityState={{ selected: isSelected, disabled: isDisabled }}
|
|
42
|
+
aria-selected={isSelected}
|
|
43
|
+
aria-disabled={isDisabled}
|
|
44
|
+
disabled={isDisabled}
|
|
45
|
+
hitSlop={touchTargetHitSlopForSize(chipHeight)}
|
|
46
|
+
className={cn(chipBodyClassName, className)}
|
|
47
|
+
{...pressableProps}
|
|
48
|
+
>
|
|
49
|
+
<Text className={chipLabelVariants({ isSelected })}>{label}</Text>
|
|
50
|
+
</Pressable>
|
|
51
|
+
{onDismiss ? (
|
|
52
|
+
<Pressable
|
|
53
|
+
accessibilityRole="button"
|
|
54
|
+
accessibilityLabel={dismissAccessibilityLabel ?? `Remove ${label}`}
|
|
55
|
+
aria-disabled={isDisabled}
|
|
56
|
+
disabled={isDisabled}
|
|
57
|
+
hitSlop={touchTargetHitSlopForSize(chipHeight)}
|
|
58
|
+
onPress={onDismiss}
|
|
59
|
+
className={chipDismissClassName}
|
|
60
|
+
>
|
|
61
|
+
<X className={chipLabelVariants({ isSelected })} size={iconSize.sm} />
|
|
62
|
+
</Pressable>
|
|
63
|
+
) : null}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|