@ponti-studios/ui 1.2.0 → 1.4.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/package.json +1 -1
- package/src/components/data-display/accordion.tsx +10 -1
- package/src/components/feedback/empty-state.tsx +1 -1
- package/src/components/feedback/file-upload-status.tsx +14 -6
- package/src/components/feedback/progress-bar.tsx +3 -2
- package/src/components/feedback/spinner.tsx +3 -4
- package/src/components/feedback/update-guard.stories.tsx +1 -1
- package/src/components/feedback/update-guard.tsx +2 -2
- package/src/components/filters/active-filters-bar.stories.tsx +2 -0
- package/src/components/filters/active-filters-bar.tsx +21 -23
- package/src/components/filters/filter-select.tsx +4 -2
- package/src/components/filters/index.ts +0 -4
- package/src/components/forms/date-month-select.tsx +2 -2
- package/src/components/forms/date-picker.tsx +2 -2
- package/src/components/forms/drop-zone.stories.tsx +112 -28
- package/src/components/forms/drop-zone.tsx +265 -112
- package/src/components/forms/input.tsx +5 -1
- package/src/components/forms/radio-group.tsx +3 -2
- package/src/components/forms/select.tsx +14 -4
- package/src/components/forms/slider.stories.tsx +2 -2
- package/src/components/forms/slider.tsx +12 -9
- package/src/components/forms/switch.tsx +5 -2
- package/src/components/forms/textarea.tsx +5 -1
- package/src/components/layout/index.ts +0 -2
- package/src/components/layout/section-intro.tsx +1 -1
- package/src/components/layout/surface-panel.tsx +10 -2
- package/src/components/navigation/app-navigation.tsx +43 -7
- package/src/components/navigation/tabs.tsx +4 -1
- package/src/components/overlays/command.tsx +39 -14
- package/src/components/overlays/popover.stories.tsx +16 -4
- package/src/components/overlays/sheet.tsx +11 -3
- package/src/components/primitives/badge.tsx +1 -1
- package/src/components/primitives/button.tsx +6 -1
- package/src/styles/index.css +1 -0
- package/src/styles/nokia.css +38 -0
- package/src/components/filters/filters.stories.tsx +0 -72
- package/src/components/filters/filters.tsx +0 -72
- package/src/components/filters/search-filter-bar.stories.tsx +0 -39
- package/src/components/filters/search-filter-bar.tsx +0 -46
|
@@ -7,13 +7,36 @@ import { Slot } from "../../lib/slot";
|
|
|
7
7
|
import { Sheet, SheetClose, SheetContent, SheetTitle, SheetTrigger } from "../overlays/sheet";
|
|
8
8
|
import { Button } from "../primitives/button";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
type MobileMenuState = {
|
|
11
|
+
/** True while rendering inside the mobile Sheet copy of the nav content. */
|
|
12
|
+
isMobile: boolean;
|
|
13
|
+
variant?: NavigationVariant;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const MobileMenuContext = React.createContext<MobileMenuState>({ isMobile: false });
|
|
17
|
+
|
|
18
|
+
/** @internal Undocumented — not part of the public API surface. */
|
|
19
|
+
type NavigationVariant = "nokia";
|
|
20
|
+
|
|
21
|
+
// Nokia 3310-style monochrome LCD skin for the mobile Sheet. See
|
|
22
|
+
// src/styles/nokia.css for the palette/font tokens and scanline texture.
|
|
23
|
+
const NOKIA_SHEET_CLASSNAME =
|
|
24
|
+
"nokia-screen rounded-none border-[6px] border-nokia-bezel bg-nokia-screen p-4 font-nokia text-nokia-ink shadow-[0_0_0_3px_var(--color-nokia-screen-deep),0_10px_30px_-6px_rgba(0,0,0,0.65)]";
|
|
25
|
+
const NOKIA_SHEET_CLOSE_CLASSNAME =
|
|
26
|
+
"text-nokia-ink hover:bg-nokia-ink hover:text-nokia-screen rounded-none border-2 border-nokia-ink";
|
|
27
|
+
const NOKIA_LIST_CLASSNAME = "!grid grid-cols-3 gap-3";
|
|
28
|
+
const NOKIA_ITEM_CLASSNAME =
|
|
29
|
+
"!h-auto !max-h-none w-full flex-col justify-center gap-1 rounded-none border-2 border-nokia-ink/40 px-2 py-3 text-center font-nokia text-base tracking-widest uppercase hover:bg-transparent active:bg-nokia-ink active:text-nokia-screen data-[active]:border-nokia-ink data-[active]:bg-nokia-ink data-[active]:text-nokia-screen";
|
|
12
30
|
|
|
13
31
|
export interface NavigationRootProps {
|
|
14
32
|
children: ReactNode;
|
|
15
33
|
ariaLabel?: string;
|
|
16
34
|
className?: string;
|
|
35
|
+
/**
|
|
36
|
+
* @internal Undocumented mobile-Sheet skin, not part of the public API
|
|
37
|
+
* (no README/Storybook entry). Opt in at your own risk.
|
|
38
|
+
*/
|
|
39
|
+
variant?: NavigationVariant;
|
|
17
40
|
}
|
|
18
41
|
|
|
19
42
|
/**
|
|
@@ -30,8 +53,10 @@ function NavigationRoot({
|
|
|
30
53
|
children,
|
|
31
54
|
ariaLabel = "Primary navigation",
|
|
32
55
|
className,
|
|
56
|
+
variant,
|
|
33
57
|
}: NavigationRootProps) {
|
|
34
58
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
|
59
|
+
const isNokia = variant === "nokia";
|
|
35
60
|
|
|
36
61
|
const items = React.Children.toArray(children);
|
|
37
62
|
const brand = items.find(
|
|
@@ -63,10 +88,15 @@ function NavigationRoot({
|
|
|
63
88
|
<Menu className="size-4" aria-hidden="true" />
|
|
64
89
|
</Button>
|
|
65
90
|
</SheetTrigger>
|
|
66
|
-
<SheetContent
|
|
91
|
+
<SheetContent
|
|
92
|
+
className={isNokia ? NOKIA_SHEET_CLASSNAME : undefined}
|
|
93
|
+
closeClassName={isNokia ? NOKIA_SHEET_CLOSE_CLASSNAME : undefined}
|
|
94
|
+
>
|
|
67
95
|
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
|
|
68
96
|
<div className="mt-8 flex flex-1 flex-col gap-4">
|
|
69
|
-
<MobileMenuContext.Provider value={true}
|
|
97
|
+
<MobileMenuContext.Provider value={{ isMobile: true, variant }}>
|
|
98
|
+
{rest}
|
|
99
|
+
</MobileMenuContext.Provider>
|
|
70
100
|
</div>
|
|
71
101
|
</SheetContent>
|
|
72
102
|
</Sheet>
|
|
@@ -93,12 +123,14 @@ export interface NavigationListProps {
|
|
|
93
123
|
|
|
94
124
|
/** Semantic `<ul>` of `Navigation.Item`s. Lays out horizontally on desktop, stacks inside the mobile Sheet. */
|
|
95
125
|
function NavigationList({ children, className }: NavigationListProps) {
|
|
96
|
-
const isMobile = React.useContext(MobileMenuContext);
|
|
126
|
+
const { isMobile, variant } = React.useContext(MobileMenuContext);
|
|
127
|
+
const isNokia = isMobile && variant === "nokia";
|
|
97
128
|
return (
|
|
98
129
|
<ul
|
|
99
130
|
className={cn(
|
|
100
131
|
"flex items-center gap-1",
|
|
101
132
|
isMobile && "flex-col items-stretch gap-1",
|
|
133
|
+
isNokia && NOKIA_LIST_CLASSNAME,
|
|
102
134
|
className,
|
|
103
135
|
)}
|
|
104
136
|
>
|
|
@@ -124,9 +156,11 @@ function NavigationItem({
|
|
|
124
156
|
children,
|
|
125
157
|
...props
|
|
126
158
|
}: NavigationItemProps) {
|
|
127
|
-
const isMobile = React.useContext(MobileMenuContext);
|
|
159
|
+
const { isMobile, variant } = React.useContext(MobileMenuContext);
|
|
160
|
+
const isNokia = isMobile && variant === "nokia";
|
|
128
161
|
const itemClassName = cn(
|
|
129
162
|
"inline-flex h-9 max-h-9 shrink-0 items-center px-3 py-2 text-sm font-medium hover:bg-muted hover:text-muted-foreground data-[active]:font-semibold data-[active]:text-foreground",
|
|
163
|
+
isNokia && NOKIA_ITEM_CLASSNAME,
|
|
130
164
|
className,
|
|
131
165
|
);
|
|
132
166
|
|
|
@@ -166,12 +200,14 @@ export interface NavigationActionProps {
|
|
|
166
200
|
* for a link that should close the menu when tapped.
|
|
167
201
|
*/
|
|
168
202
|
function NavigationAction({ children, className }: NavigationActionProps) {
|
|
169
|
-
const isMobile = React.useContext(MobileMenuContext);
|
|
203
|
+
const { isMobile, variant } = React.useContext(MobileMenuContext);
|
|
204
|
+
const isNokia = isMobile && variant === "nokia";
|
|
170
205
|
return (
|
|
171
206
|
<div
|
|
172
207
|
className={cn(
|
|
173
208
|
"flex items-center gap-3",
|
|
174
209
|
isMobile && "mt-2 flex-col items-stretch gap-3 border-t pt-4",
|
|
210
|
+
isNokia && "border-nokia-ink/40",
|
|
175
211
|
className,
|
|
176
212
|
)}
|
|
177
213
|
>
|
|
@@ -25,7 +25,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
|
|
|
25
25
|
<TabsPrimitive.Tab
|
|
26
26
|
data-slot="tabs-trigger"
|
|
27
27
|
className={cn(
|
|
28
|
-
"text-foreground
|
|
28
|
+
"text-foreground flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-sm font-medium whitespace-nowrap transition-all",
|
|
29
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
30
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
|
31
|
+
"data-active:ring-1 data-active:ring-inset data-active:bg-background data-active:text-foreground data-active:ring-border",
|
|
29
32
|
className,
|
|
30
33
|
)}
|
|
31
34
|
{...props}
|
|
@@ -2,6 +2,7 @@ import { Combobox as CommandPrimitive } from "@base-ui/react/combobox";
|
|
|
2
2
|
import { SearchIcon } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
5
6
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./dialog";
|
|
6
7
|
|
|
7
8
|
function Command({
|
|
@@ -14,7 +15,10 @@ function Command({
|
|
|
14
15
|
return (
|
|
15
16
|
<div
|
|
16
17
|
data-slot="command"
|
|
17
|
-
className={
|
|
18
|
+
className={cn(
|
|
19
|
+
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
18
22
|
>
|
|
19
23
|
<CommandPrimitive.Root {...props}>{children}</CommandPrimitive.Root>
|
|
20
24
|
</div>
|
|
@@ -63,7 +67,11 @@ function CommandInput({
|
|
|
63
67
|
<CommandPrimitive.Input
|
|
64
68
|
data-slot="command-input"
|
|
65
69
|
aria-label={ariaLabel}
|
|
66
|
-
className={
|
|
70
|
+
className={cn(
|
|
71
|
+
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden",
|
|
72
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
67
75
|
onChange={(event) => {
|
|
68
76
|
onChange?.(event);
|
|
69
77
|
onValueChange?.(event.currentTarget.value);
|
|
@@ -74,14 +82,18 @@ function CommandInput({
|
|
|
74
82
|
);
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
function CommandList({
|
|
85
|
+
function CommandList({
|
|
86
|
+
children,
|
|
87
|
+
className,
|
|
88
|
+
...props
|
|
89
|
+
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
|
78
90
|
if (typeof children === "function") {
|
|
79
91
|
return (
|
|
80
92
|
<CommandPrimitive.List
|
|
81
93
|
data-slot="command-list"
|
|
82
94
|
aria-label="Command results"
|
|
83
95
|
tabIndex={0}
|
|
84
|
-
className="max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto"
|
|
96
|
+
className={cn("max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
|
|
85
97
|
{...props}
|
|
86
98
|
>
|
|
87
99
|
{children}
|
|
@@ -103,7 +115,7 @@ function CommandList({ children, ...props }: React.ComponentProps<typeof Command
|
|
|
103
115
|
data-slot="command-list"
|
|
104
116
|
aria-label="Command results"
|
|
105
117
|
tabIndex={0}
|
|
106
|
-
className="max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto"
|
|
118
|
+
className={cn("max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
|
|
107
119
|
{...props}
|
|
108
120
|
>
|
|
109
121
|
{listChildren}
|
|
@@ -113,11 +125,14 @@ function CommandList({ children, ...props }: React.ComponentProps<typeof Command
|
|
|
113
125
|
);
|
|
114
126
|
}
|
|
115
127
|
|
|
116
|
-
function CommandEmpty({
|
|
128
|
+
function CommandEmpty({
|
|
129
|
+
className,
|
|
130
|
+
...props
|
|
131
|
+
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
|
117
132
|
return (
|
|
118
133
|
<CommandPrimitive.Empty
|
|
119
134
|
data-slot="command-empty"
|
|
120
|
-
className="py-6 text-center text-sm"
|
|
135
|
+
className={cn("py-6 text-center text-sm", className)}
|
|
121
136
|
{...props}
|
|
122
137
|
/>
|
|
123
138
|
);
|
|
@@ -132,7 +147,7 @@ function CommandGroup({
|
|
|
132
147
|
return (
|
|
133
148
|
<CommandPrimitive.Group
|
|
134
149
|
data-slot="command-group"
|
|
135
|
-
className={
|
|
150
|
+
className={cn("text-foreground overflow-hidden p-1", className)}
|
|
136
151
|
{...props}
|
|
137
152
|
>
|
|
138
153
|
{heading ? (
|
|
@@ -153,7 +168,11 @@ function CommandItem({ className, onSelect, onClick, ...props }: CommandItemProp
|
|
|
153
168
|
return (
|
|
154
169
|
<CommandPrimitive.Item
|
|
155
170
|
data-slot="command-item"
|
|
156
|
-
className={
|
|
171
|
+
className={cn(
|
|
172
|
+
"data-highlighted:bg-muted data-highlighted:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none",
|
|
173
|
+
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
|
174
|
+
className,
|
|
175
|
+
)}
|
|
157
176
|
onClick={(event) => {
|
|
158
177
|
onClick?.(event);
|
|
159
178
|
onSelect?.();
|
|
@@ -168,24 +187,30 @@ function CommandSeparator({ className, ...props }: React.ComponentProps<"div">)
|
|
|
168
187
|
<div
|
|
169
188
|
data-slot="command-separator"
|
|
170
189
|
aria-hidden="true"
|
|
171
|
-
className={
|
|
190
|
+
className={cn("bg-border -mx-1 h-px", className)}
|
|
172
191
|
{...props}
|
|
173
192
|
/>
|
|
174
193
|
);
|
|
175
194
|
}
|
|
176
195
|
|
|
177
|
-
function CommandShortcut(props: React.ComponentProps<"span">) {
|
|
196
|
+
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
|
178
197
|
return (
|
|
179
198
|
<span
|
|
180
199
|
data-slot="command-shortcut"
|
|
181
|
-
className="text-muted-foreground ml-auto text-xs tracking-widest"
|
|
200
|
+
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
|
182
201
|
{...props}
|
|
183
202
|
/>
|
|
184
203
|
);
|
|
185
204
|
}
|
|
186
205
|
|
|
187
|
-
function CommandListLoading(props: React.ComponentProps<"div">) {
|
|
188
|
-
return
|
|
206
|
+
function CommandListLoading({ className, ...props }: React.ComponentProps<"div">) {
|
|
207
|
+
return (
|
|
208
|
+
<div
|
|
209
|
+
data-slot="command-list-loading"
|
|
210
|
+
className={cn("py-6 text-center text-sm", className)}
|
|
211
|
+
{...props}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
189
214
|
}
|
|
190
215
|
|
|
191
216
|
export {
|
|
@@ -25,12 +25,24 @@ export const Default: Story = {
|
|
|
25
25
|
</div>
|
|
26
26
|
<div className="grid gap-4 pt-4">
|
|
27
27
|
<div className="grid grid-cols-3 items-center gap-4">
|
|
28
|
-
<label className="text-sm">
|
|
29
|
-
|
|
28
|
+
<label htmlFor="popover-width" className="text-sm">
|
|
29
|
+
Width
|
|
30
|
+
</label>
|
|
31
|
+
<input
|
|
32
|
+
id="popover-width"
|
|
33
|
+
className="col-span-2 h-8 rounded border px-2 text-sm"
|
|
34
|
+
defaultValue="100%"
|
|
35
|
+
/>
|
|
30
36
|
</div>
|
|
31
37
|
<div className="grid grid-cols-3 items-center gap-4">
|
|
32
|
-
<label className="text-sm">
|
|
33
|
-
|
|
38
|
+
<label htmlFor="popover-height" className="text-sm">
|
|
39
|
+
Height
|
|
40
|
+
</label>
|
|
41
|
+
<input
|
|
42
|
+
id="popover-height"
|
|
43
|
+
className="col-span-2 h-8 rounded border px-2 text-sm"
|
|
44
|
+
defaultValue="25px"
|
|
45
|
+
/>
|
|
34
46
|
</div>
|
|
35
47
|
</div>
|
|
36
48
|
</PopoverContent>
|
|
@@ -72,12 +72,15 @@ const sheetClassName = [
|
|
|
72
72
|
"md:w-3/4 md:max-w-sm md:min-h-0",
|
|
73
73
|
];
|
|
74
74
|
|
|
75
|
-
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Popup> {
|
|
75
|
+
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Popup> {
|
|
76
|
+
/** Applied to the built-in close button, alongside its default styling. */
|
|
77
|
+
closeClassName?: string;
|
|
78
|
+
}
|
|
76
79
|
|
|
77
80
|
const SheetContent = React.forwardRef<
|
|
78
81
|
React.ComponentRef<typeof SheetPrimitive.Popup>,
|
|
79
82
|
SheetContentProps
|
|
80
|
-
>(({ className, children, ...props }, ref) => (
|
|
83
|
+
>(({ className, closeClassName, children, ...props }, ref) => (
|
|
81
84
|
<SheetPortal>
|
|
82
85
|
<SheetOverlay />
|
|
83
86
|
<SheetPrimitive.Popup
|
|
@@ -86,7 +89,12 @@ const SheetContent = React.forwardRef<
|
|
|
86
89
|
className={cn(sheetClassName, className)}
|
|
87
90
|
{...props}
|
|
88
91
|
>
|
|
89
|
-
<SheetPrimitive.Close
|
|
92
|
+
<SheetPrimitive.Close
|
|
93
|
+
className={cn(
|
|
94
|
+
"text-muted-foreground hover:bg-muted hover:text-muted-foreground focus-visible:outline-ring absolute top-3 right-3 inline-flex size-9 items-center justify-center rounded-md border border-transparent transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none md:top-4 md:right-4",
|
|
95
|
+
closeClassName,
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
90
98
|
<X className="size-4.5" />
|
|
91
99
|
<span className="sr-only">Close</span>
|
|
92
100
|
</SheetPrimitive.Close>
|
|
@@ -12,7 +12,7 @@ const badgeVariants = cva(
|
|
|
12
12
|
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
|
|
13
13
|
destructive: "bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90",
|
|
14
14
|
outline: "border-border text-foreground [a&]:hover:bg-muted",
|
|
15
|
-
ghost: "
|
|
15
|
+
ghost: "bg-transparent text-foreground [a&]:hover:bg-muted",
|
|
16
16
|
link: "text-foreground underline-offset-4 [a&]:hover:underline",
|
|
17
17
|
/** Quiet metadata marker for real reference IDs (case/appointment/feed
|
|
18
18
|
* numbers) — never for decorative sequence markers. */
|
|
@@ -6,7 +6,12 @@ import { cn } from "../../lib/utils";
|
|
|
6
6
|
import { Spinner } from "../feedback/spinner";
|
|
7
7
|
|
|
8
8
|
const buttonVariants = cva(
|
|
9
|
-
|
|
9
|
+
[
|
|
10
|
+
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md border border-transparent font-medium transition-colors outline-none",
|
|
11
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
12
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
13
|
+
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
14
|
+
],
|
|
10
15
|
{
|
|
11
16
|
variants: {
|
|
12
17
|
variant: {
|
package/src/styles/index.css
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Undocumented "nokia" Navigation variant — Nokia 3310-style monochrome LCD
|
|
3
|
+
* skin for the mobile Sheet menu. Not part of the public API surface (no
|
|
4
|
+
* README/Storybook entry); opt in via `<Navigation variant="nokia">`.
|
|
5
|
+
*/
|
|
6
|
+
@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap");
|
|
7
|
+
|
|
8
|
+
@theme {
|
|
9
|
+
--color-nokia-screen: #c8d6a6;
|
|
10
|
+
--color-nokia-screen-deep: #b0c188;
|
|
11
|
+
--color-nokia-ink: #26331b;
|
|
12
|
+
--color-nokia-bezel: #1a1a1a;
|
|
13
|
+
--font-nokia: "VT323", "Courier New", monospace;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* No `position: relative` here — SheetContent is `position: fixed` via its
|
|
17
|
+
* own Tailwind class, and a same-specificity `.nokia-screen { position:
|
|
18
|
+
* relative }` loaded later in the cascade would silently win and break the
|
|
19
|
+
* sheet's fixed/inset layout (bit us once already; see labs' app.css). The
|
|
20
|
+
* `::before` overlay below still gets a positioning context from the
|
|
21
|
+
* ancestor's `fixed`, so this class never needs to set position itself. */
|
|
22
|
+
.nokia-screen {
|
|
23
|
+
background-image: repeating-linear-gradient(
|
|
24
|
+
to bottom,
|
|
25
|
+
rgb(0 0 0 / 0.05) 0,
|
|
26
|
+
rgb(0 0 0 / 0.05) 1px,
|
|
27
|
+
transparent 1px,
|
|
28
|
+
transparent 3px
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.nokia-screen::before {
|
|
33
|
+
content: "";
|
|
34
|
+
position: absolute;
|
|
35
|
+
inset: 0;
|
|
36
|
+
box-shadow: inset 0 0 12px 2px rgb(0 0 0 / 0.25);
|
|
37
|
+
pointer-events: none;
|
|
38
|
+
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { expect, within } from "storybook/test";
|
|
3
|
-
|
|
4
|
-
import { Input } from "../forms/input";
|
|
5
|
-
import { Filters } from "./filters";
|
|
6
|
-
|
|
7
|
-
const meta = {
|
|
8
|
-
title: "Filters/Compound",
|
|
9
|
-
component: Filters,
|
|
10
|
-
tags: ["autodocs"],
|
|
11
|
-
args: {
|
|
12
|
-
children: null,
|
|
13
|
-
},
|
|
14
|
-
} satisfies Meta<typeof Filters>;
|
|
15
|
-
|
|
16
|
-
export default meta;
|
|
17
|
-
type Story = StoryObj<typeof meta>;
|
|
18
|
-
|
|
19
|
-
const statusOptions = [
|
|
20
|
-
{ value: "active", label: "Active" },
|
|
21
|
-
{ value: "interview", label: "Interview" },
|
|
22
|
-
{ value: "offer", label: "Offer" },
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
export const Default: Story = {
|
|
26
|
-
render: () => (
|
|
27
|
-
<div className="w-[720px]">
|
|
28
|
-
<Filters>
|
|
29
|
-
<Filters.Search label="Search">
|
|
30
|
-
<Input placeholder="Search applications..." aria-label="Search applications" />
|
|
31
|
-
</Filters.Search>
|
|
32
|
-
<Filters.Select
|
|
33
|
-
value="active"
|
|
34
|
-
options={statusOptions}
|
|
35
|
-
onChange={() => {}}
|
|
36
|
-
placeholder="All statuses"
|
|
37
|
-
label="Status"
|
|
38
|
-
/>
|
|
39
|
-
<Filters.Select
|
|
40
|
-
value=""
|
|
41
|
-
options={[{ value: "engineer", label: "Engineer" }]}
|
|
42
|
-
onChange={() => {}}
|
|
43
|
-
placeholder="All roles"
|
|
44
|
-
label="Role"
|
|
45
|
-
/>
|
|
46
|
-
<Filters.Active
|
|
47
|
-
filters={[
|
|
48
|
-
{ id: "status", label: "Status: Active", onRemove: () => {} },
|
|
49
|
-
{ id: "sort", label: "Date", onRemove: () => {}, variant: "sort", direction: "desc" },
|
|
50
|
-
]}
|
|
51
|
-
onClearAll={() => {}}
|
|
52
|
-
/>
|
|
53
|
-
</Filters>
|
|
54
|
-
</div>
|
|
55
|
-
),
|
|
56
|
-
play: async ({ canvasElement }) => {
|
|
57
|
-
const canvas = within(canvasElement);
|
|
58
|
-
|
|
59
|
-
await expect(canvas.getByText("Search")).toBeInTheDocument();
|
|
60
|
-
await expect(canvas.getByRole("textbox", { name: "Search applications" })).toBeInTheDocument();
|
|
61
|
-
|
|
62
|
-
await expect(canvas.getByText("Status")).toBeInTheDocument();
|
|
63
|
-
await expect(canvas.getByRole("combobox", { name: "Status" })).toBeInTheDocument();
|
|
64
|
-
|
|
65
|
-
await expect(canvas.getByText("Role")).toBeInTheDocument();
|
|
66
|
-
await expect(canvas.getByRole("combobox", { name: "Role" })).toBeInTheDocument();
|
|
67
|
-
|
|
68
|
-
await expect(canvas.getByText("Status: Active")).toBeInTheDocument();
|
|
69
|
-
await expect(canvas.getByText("Date")).toBeInTheDocument();
|
|
70
|
-
await expect(canvas.getByRole("button", { name: "Clear all" })).toBeInTheDocument();
|
|
71
|
-
},
|
|
72
|
-
};
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { Children, isValidElement, type ReactNode } from "react";
|
|
2
|
-
|
|
3
|
-
import { cn } from "../../lib/utils";
|
|
4
|
-
import { ActiveFiltersBar, type ActiveFiltersBarProps } from "./active-filters-bar";
|
|
5
|
-
import { FilterSelect, type FilterSelectProps } from "./filter-select";
|
|
6
|
-
import { Label } from "../primitives/label";
|
|
7
|
-
|
|
8
|
-
export function FiltersSearch({ label, children }: { label: string; children: ReactNode }) {
|
|
9
|
-
const id = `filters-search-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
|
10
|
-
return (
|
|
11
|
-
<div className="flex-1 space-y-2">
|
|
12
|
-
<Label htmlFor={id}>{label}</Label>
|
|
13
|
-
{children}
|
|
14
|
-
</div>
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
FiltersSearch.slot = "search";
|
|
18
|
-
|
|
19
|
-
export function FiltersSelect(props: FilterSelectProps) {
|
|
20
|
-
return <FilterSelect {...props} label={props.label ?? props.placeholder} />;
|
|
21
|
-
}
|
|
22
|
-
FiltersSelect.slot = "select";
|
|
23
|
-
|
|
24
|
-
export function FiltersActive(props: ActiveFiltersBarProps) {
|
|
25
|
-
return <ActiveFiltersBar {...props} />;
|
|
26
|
-
}
|
|
27
|
-
FiltersActive.slot = "active";
|
|
28
|
-
|
|
29
|
-
export interface FiltersProps {
|
|
30
|
-
children: ReactNode;
|
|
31
|
-
className?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function FiltersRoot({ children, className }: FiltersProps) {
|
|
35
|
-
const search: ReactNode[] = [];
|
|
36
|
-
const selects: ReactNode[] = [];
|
|
37
|
-
const active: ReactNode[] = [];
|
|
38
|
-
|
|
39
|
-
Children.forEach(children, (child) => {
|
|
40
|
-
if (!isValidElement(child)) return;
|
|
41
|
-
const slot = (child.type as any)?.slot;
|
|
42
|
-
if (slot === "search") search.push(child);
|
|
43
|
-
else if (slot === "select") selects.push(child);
|
|
44
|
-
else if (slot === "active") active.push(child);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
return (
|
|
48
|
-
<div className={cn("flex flex-col gap-4", className)}>
|
|
49
|
-
<div className="flex flex-col gap-3 lg:flex-row lg:items-start">
|
|
50
|
-
{search}
|
|
51
|
-
{selects.map((s, i) => (
|
|
52
|
-
<div key={i} className="sm:w-48">
|
|
53
|
-
{s}
|
|
54
|
-
</div>
|
|
55
|
-
))}
|
|
56
|
-
</div>
|
|
57
|
-
{active}
|
|
58
|
-
</div>
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
type FiltersComponent = typeof FiltersRoot & {
|
|
63
|
-
Search: typeof FiltersSearch;
|
|
64
|
-
Select: typeof FiltersSelect;
|
|
65
|
-
Active: typeof FiltersActive;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
export const Filters: FiltersComponent = Object.assign(FiltersRoot, {
|
|
69
|
-
Search: FiltersSearch,
|
|
70
|
-
Select: FiltersSelect,
|
|
71
|
-
Active: FiltersActive,
|
|
72
|
-
});
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
|
|
3
|
-
import { Input } from "../forms/input";
|
|
4
|
-
import { FilterSelect } from "./filter-select";
|
|
5
|
-
import { SearchFilterBar } from "./search-filter-bar";
|
|
6
|
-
|
|
7
|
-
const meta = {
|
|
8
|
-
title: "Filters/SearchFilterBar",
|
|
9
|
-
component: SearchFilterBar,
|
|
10
|
-
tags: ["autodocs"],
|
|
11
|
-
} satisfies Meta<typeof SearchFilterBar>;
|
|
12
|
-
|
|
13
|
-
export default meta;
|
|
14
|
-
type Story = StoryObj<typeof meta>;
|
|
15
|
-
|
|
16
|
-
export const Legacy: Story = {
|
|
17
|
-
args: {
|
|
18
|
-
activeFilters: [],
|
|
19
|
-
},
|
|
20
|
-
render: () => (
|
|
21
|
-
<div className="w-[720px]">
|
|
22
|
-
<SearchFilterBar
|
|
23
|
-
activeFilters={[{ id: "status", label: "Status: Active", onRemove: () => {} }]}
|
|
24
|
-
onClearAll={() => {}}
|
|
25
|
-
search={<Input placeholder="Search..." aria-label="Search" />}
|
|
26
|
-
filters={[
|
|
27
|
-
<FilterSelect
|
|
28
|
-
key="status"
|
|
29
|
-
value="active"
|
|
30
|
-
options={[{ value: "active", label: "Active" }]}
|
|
31
|
-
onChange={() => {}}
|
|
32
|
-
placeholder="All"
|
|
33
|
-
label="Status"
|
|
34
|
-
/>,
|
|
35
|
-
]}
|
|
36
|
-
/>
|
|
37
|
-
</div>
|
|
38
|
-
),
|
|
39
|
-
};
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
2
|
-
|
|
3
|
-
import { cn } from "../../lib/utils";
|
|
4
|
-
import { ActiveFiltersBar, type ActiveFilter } from "./active-filters-bar";
|
|
5
|
-
|
|
6
|
-
export interface SearchFilterBarProps {
|
|
7
|
-
activeFilters: ActiveFilter[];
|
|
8
|
-
onClearAll?: () => void;
|
|
9
|
-
search?: ReactNode;
|
|
10
|
-
filters?: ReactNode[];
|
|
11
|
-
results?: ReactNode;
|
|
12
|
-
children?: ReactNode;
|
|
13
|
-
className?: string;
|
|
14
|
-
filterItemClassName?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function SearchFilterBar({
|
|
18
|
-
activeFilters,
|
|
19
|
-
onClearAll,
|
|
20
|
-
search,
|
|
21
|
-
filters,
|
|
22
|
-
results,
|
|
23
|
-
children,
|
|
24
|
-
className,
|
|
25
|
-
filterItemClassName = "sm:w-48",
|
|
26
|
-
}: SearchFilterBarProps) {
|
|
27
|
-
return (
|
|
28
|
-
<div className={cn("flex flex-col gap-4", className)}>
|
|
29
|
-
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
30
|
-
{search ? <div className="flex-1">{search}</div> : null}
|
|
31
|
-
|
|
32
|
-
{filters?.map((filter, index) => (
|
|
33
|
-
<div key={index} className={filterItemClassName}>
|
|
34
|
-
{filter}
|
|
35
|
-
</div>
|
|
36
|
-
))}
|
|
37
|
-
|
|
38
|
-
{results ? <div className="self-start lg:ml-auto lg:self-auto">{results}</div> : null}
|
|
39
|
-
|
|
40
|
-
{children}
|
|
41
|
-
</div>
|
|
42
|
-
|
|
43
|
-
<ActiveFiltersBar filters={activeFilters} onClearAll={onClearAll} />
|
|
44
|
-
</div>
|
|
45
|
-
);
|
|
46
|
-
}
|