@ponti-studios/ui 1.3.0 → 1.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponti-studios/ui",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "private": false,
5
5
  "description": "Shared UI component library for Ponti Studios",
6
6
  "license": "MIT",
@@ -17,7 +17,7 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
17
17
  month: "flex flex-col gap-4",
18
18
  month_caption: "relative flex h-8 items-center justify-center",
19
19
  caption_label: "text-sm font-medium",
20
- nav: "absolute inset-x-4 top-4 flex items-center justify-between",
20
+ nav: "absolute inset-x-4 top-4 z-10 flex items-center justify-between",
21
21
  button_previous:
22
22
  "inline-flex size-8 p-2 items-center justify-center rounded-full border bg-background text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
23
23
  button_next:
@@ -0,0 +1,164 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, screen, userEvent, within } from "storybook/test";
3
+
4
+ import { DateField } from "./date-field";
5
+
6
+ const meta = {
7
+ title: "Forms/DateField",
8
+ component: DateField,
9
+ tags: ["autodocs"],
10
+ } satisfies Meta<typeof DateField>;
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ function hiddenValue(canvasElement: HTMLElement, name: string): string | null {
16
+ const input = canvasElement.querySelector(`input[name="${name}"]`) as HTMLInputElement | null;
17
+ return input?.value ?? null;
18
+ }
19
+
20
+ async function nextMonth(dialog: HTMLElement) {
21
+ await userEvent.click(within(dialog).getByRole("button", { name: "Go to the Next Month" }));
22
+ }
23
+
24
+ // The trigger button's accessible name comes from its <label> (htmlFor), so
25
+ // stories query the button by label and assert the visible date text separately.
26
+ export const WithStringDefaultValue: Story = {
27
+ args: {
28
+ name: "startDate",
29
+ defaultValue: "2020-01-15",
30
+ label: "Start date",
31
+ },
32
+ play: async ({ canvasElement }) => {
33
+ const canvas = within(canvasElement);
34
+
35
+ // The hidden input carries the local "YYYY-MM-DD" value, and the trigger
36
+ // shows the formatted date.
37
+ await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-01-15");
38
+ const trigger = canvas.getByRole("button", { name: "Start date" });
39
+ await expect(trigger).toHaveTextContent("Jan 15, 2020");
40
+
41
+ // The calendar opens on the selected month, not the current one.
42
+ await userEvent.click(trigger);
43
+ const dialog = await screen.findByRole("dialog");
44
+ await expect(within(dialog).getByText("January 2020")).toBeInTheDocument();
45
+
46
+ // Real click on the Next chevron navigates the month.
47
+ await nextMonth(dialog);
48
+ await expect(within(dialog).getByText("February 2020")).toBeInTheDocument();
49
+
50
+ // Picking a day updates the trigger and the hidden input.
51
+ await userEvent.click(within(dialog).getByText("15"));
52
+ await expect(canvas.getByRole("button", { name: "Start date" })).toHaveTextContent(
53
+ "Feb 15, 2020",
54
+ );
55
+ await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-02-15");
56
+ },
57
+ };
58
+
59
+ export const WithDateDefaultValue: Story = {
60
+ args: {
61
+ name: "endDate",
62
+ defaultValue: new Date(2020, 0, 15),
63
+ label: "End date",
64
+ },
65
+ play: async ({ canvasElement }) => {
66
+ const canvas = within(canvasElement);
67
+
68
+ await expect(hiddenValue(canvasElement, "endDate")).toBe("2020-01-15");
69
+ await expect(canvas.getByRole("button", { name: "End date" })).toHaveTextContent(
70
+ "Jan 15, 2020",
71
+ );
72
+ },
73
+ };
74
+
75
+ export const Empty: Story = {
76
+ args: {
77
+ name: "startDate",
78
+ label: "Start date",
79
+ },
80
+ play: async ({ canvasElement }) => {
81
+ const canvas = within(canvasElement);
82
+
83
+ await expect(hiddenValue(canvasElement, "startDate")).toBe("");
84
+ await expect(canvas.getByRole("button", { name: "Start date" })).toHaveTextContent(
85
+ "Pick a date",
86
+ );
87
+ },
88
+ };
89
+
90
+ export const Disabled: Story = {
91
+ args: {
92
+ name: "startDate",
93
+ defaultValue: "2020-01-15",
94
+ label: "Start date",
95
+ disabled: true,
96
+ },
97
+ play: async ({ canvasElement }) => {
98
+ const canvas = within(canvasElement);
99
+
100
+ await expect(canvas.getByRole("button", { name: "Start date" })).toBeDisabled();
101
+ await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-01-15");
102
+ },
103
+ };
104
+
105
+ export const WithRangeDefaultValue: Story = {
106
+ args: {
107
+ mode: "range",
108
+ startName: "startDate",
109
+ endName: "endDate",
110
+ defaultValue: { from: "2020-01-15", to: "2020-06-30" },
111
+ label: "Active period",
112
+ },
113
+ play: async ({ canvasElement }) => {
114
+ const canvas = within(canvasElement);
115
+
116
+ // Both hidden inputs carry local date strings; the trigger shows the range.
117
+ await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-01-15");
118
+ await expect(hiddenValue(canvasElement, "endDate")).toBe("2020-06-30");
119
+ const trigger = canvas.getByRole("button", { name: "Active period" });
120
+ await expect(trigger).toHaveTextContent("Jan 15, 2020");
121
+ await expect(trigger).toHaveTextContent("Jun 30, 2020");
122
+
123
+ // The range calendar opens on the start month.
124
+ await userEvent.click(trigger);
125
+ const dialog = await screen.findByRole("dialog");
126
+ await expect(within(dialog).getByText("January 2020")).toBeInTheDocument();
127
+
128
+ // Real-click the Next chevron, then pick a new from date.
129
+ await nextMonth(dialog);
130
+ await expect(within(dialog).getByText("February 2020")).toBeInTheDocument();
131
+ await userEvent.click(within(dialog).getByText("20"));
132
+ await expect(canvas.getByRole("button", { name: "Active period" })).toHaveTextContent(
133
+ "From Feb 20, 2020",
134
+ );
135
+ await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-02-20");
136
+ await expect(hiddenValue(canvasElement, "endDate")).toBe("");
137
+
138
+ // Picking an end date fills the second hidden input.
139
+ await userEvent.click(within(dialog).getByText("25"));
140
+ await expect(canvas.getByRole("button", { name: "Active period" })).toHaveTextContent(
141
+ "Feb 20, 2020 – Feb 25, 2020",
142
+ );
143
+ await expect(hiddenValue(canvasElement, "startDate")).toBe("2020-02-20");
144
+ await expect(hiddenValue(canvasElement, "endDate")).toBe("2020-02-25");
145
+ },
146
+ };
147
+
148
+ export const EmptyRange: Story = {
149
+ args: {
150
+ mode: "range",
151
+ startName: "startDate",
152
+ endName: "endDate",
153
+ label: "Active period",
154
+ },
155
+ play: async ({ canvasElement }) => {
156
+ const canvas = within(canvasElement);
157
+
158
+ await expect(hiddenValue(canvasElement, "startDate")).toBe("");
159
+ await expect(hiddenValue(canvasElement, "endDate")).toBe("");
160
+ await expect(canvas.getByRole("button", { name: "Active period" })).toHaveTextContent(
161
+ "Pick a date range",
162
+ );
163
+ },
164
+ };
@@ -0,0 +1,174 @@
1
+ import { format } from "date-fns";
2
+ import { CalendarIcon } from "lucide-react";
3
+ import { useId, useState } from "react";
4
+
5
+ import { Popover, PopoverContent, PopoverTrigger } from "../overlays/popover";
6
+ import { Button } from "../primitives/button";
7
+ import { Calendar } from "./calendar";
8
+ import { formatDateInput, parseDateInput } from "./date-utils";
9
+ import { DatePicker, type DatePickerProps } from "./date-picker";
10
+
11
+ export interface DateRangeValue {
12
+ from?: Date | string | null;
13
+ to?: Date | string | null;
14
+ }
15
+
16
+ interface DateFieldCommonProps {
17
+ id?: string;
18
+ label?: string;
19
+ showLabel?: boolean;
20
+ placeholder?: string;
21
+ dateFormat?: string;
22
+ disabled?: boolean;
23
+ containerClassName?: string;
24
+ popoverAlign?: DatePickerProps["popoverAlign"];
25
+ variant?: DatePickerProps["variant"];
26
+ }
27
+
28
+ export interface SingleDateFieldProps extends DateFieldCommonProps {
29
+ mode?: "single";
30
+ name: string;
31
+ defaultValue?: Date | string | null;
32
+ }
33
+
34
+ export interface RangeDateFieldProps extends DateFieldCommonProps {
35
+ mode: "range";
36
+ startName: string;
37
+ endName: string;
38
+ defaultValue?: DateRangeValue;
39
+ }
40
+
41
+ export type DateFieldProps = SingleDateFieldProps | RangeDateFieldProps;
42
+
43
+ const TRIGGER_CLASSES =
44
+ "border-border bg-background text-foreground placeholder:text-muted-foreground min-h-10 max-h-10 w-full justify-start rounded-l-none rounded-r-xl border-l-0 text-left font-normal";
45
+
46
+ function SingleDateField({
47
+ name,
48
+ defaultValue,
49
+ id,
50
+ label,
51
+ showLabel,
52
+ placeholder,
53
+ dateFormat,
54
+ disabled,
55
+ containerClassName,
56
+ popoverAlign,
57
+ variant,
58
+ }: SingleDateFieldProps) {
59
+ const generatedId = useId();
60
+ const inputId = id ?? generatedId;
61
+ const [value, setValue] = useState<Date | undefined>(() => parseDateInput(defaultValue));
62
+
63
+ return (
64
+ <div>
65
+ <DatePicker
66
+ id={inputId}
67
+ label={label}
68
+ showLabel={showLabel}
69
+ placeholder={placeholder}
70
+ dateFormat={dateFormat}
71
+ disabled={disabled}
72
+ containerClassName={containerClassName}
73
+ popoverAlign={popoverAlign}
74
+ variant={variant}
75
+ value={value}
76
+ onSelect={setValue}
77
+ />
78
+ <input type="hidden" name={name} value={value ? formatDateInput(value) : ""} />
79
+ </div>
80
+ );
81
+ }
82
+
83
+ function RangeDateField({
84
+ startName,
85
+ endName,
86
+ defaultValue,
87
+ id,
88
+ label,
89
+ showLabel = true,
90
+ placeholder = "Pick a date range",
91
+ dateFormat = "MMM d, yyyy",
92
+ disabled = false,
93
+ containerClassName = "flex-1 min-w-0",
94
+ popoverAlign = "start",
95
+ variant = "outline",
96
+ }: RangeDateFieldProps) {
97
+ const generatedId = useId();
98
+ const inputId = id ?? generatedId;
99
+ const [range, setRange] = useState<{ from?: Date; to?: Date }>(() => ({
100
+ from: parseDateInput(defaultValue?.from),
101
+ to: parseDateInput(defaultValue?.to),
102
+ }));
103
+
104
+ const fromLabel = range.from ? format(range.from, dateFormat) : null;
105
+ const toLabel = range.to ? format(range.to, dateFormat) : null;
106
+ const triggerLabel = fromLabel
107
+ ? toLabel
108
+ ? `${fromLabel} – ${toLabel}`
109
+ : `From ${fromLabel}`
110
+ : toLabel
111
+ ? `Until ${toLabel}`
112
+ : placeholder;
113
+
114
+ return (
115
+ <div className={containerClassName}>
116
+ {showLabel && label ? (
117
+ <label htmlFor={inputId} className="text-foreground mb-2 block text-sm font-medium">
118
+ {label}
119
+ </label>
120
+ ) : null}
121
+ <Popover>
122
+ <div className="flex w-full items-center">
123
+ <div className="border-border bg-background text-foreground flex size-10 shrink-0 items-center justify-center rounded-l-xl border">
124
+ <CalendarIcon className="size-4" aria-hidden />
125
+ </div>
126
+ <PopoverTrigger asChild>
127
+ <Button id={inputId} variant={variant} disabled={disabled} className={TRIGGER_CLASSES}>
128
+ {triggerLabel}
129
+ </Button>
130
+ </PopoverTrigger>
131
+ </div>
132
+ <PopoverContent
133
+ className="bg-popover w-auto p-0"
134
+ align={popoverAlign}
135
+ aria-label={label ?? "Choose a date range"}
136
+ >
137
+ <Calendar
138
+ mode="range"
139
+ selected={range.from ? { from: range.from, to: range.to } : undefined}
140
+ defaultMonth={range.from ?? range.to}
141
+ resetOnSelect
142
+ onSelect={(next) => setRange({ from: next?.from, to: next?.to })}
143
+ />
144
+ </PopoverContent>
145
+ </Popover>
146
+ <input type="hidden" name={startName} value={range.from ? formatDateInput(range.from) : ""} />
147
+ <input type="hidden" name={endName} value={range.to ? formatDateInput(range.to) : ""} />
148
+ </div>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * DateField — calendar picker backed by hidden inputs that submit "YYYY-MM-DD"
154
+ * local date strings, so forms never receive UTC-shifted dates. Supports both
155
+ * a single date (`name`) and a range (`startName` + `endName`).
156
+ *
157
+ * @example
158
+ * <DateField name="startDate" defaultValue="2020-01-15" label="Start date" />
159
+ * <DateField
160
+ * mode="range"
161
+ * startName="startDate"
162
+ * endName="endDate"
163
+ * defaultValue={{ from: "2020-01-15", to: "2020-06-30" }}
164
+ * label="Active period"
165
+ * />
166
+ */
167
+ function DateField(props: DateFieldProps) {
168
+ if (props.mode === "range") {
169
+ return <RangeDateField {...props} />;
170
+ }
171
+ return <SingleDateField {...props} />;
172
+ }
173
+
174
+ export { DateField };
@@ -55,8 +55,12 @@ export function DatePicker({
55
55
  </Button>
56
56
  </PopoverTrigger>
57
57
  </div>
58
- <PopoverContent className="bg-popover w-auto p-0" align={popoverAlign}>
59
- <Calendar mode="single" selected={value} onSelect={onSelect} />
58
+ <PopoverContent
59
+ className="bg-popover w-auto p-0"
60
+ align={popoverAlign}
61
+ aria-label={label ?? "Choose a date"}
62
+ >
63
+ <Calendar mode="single" selected={value} defaultMonth={value} onSelect={onSelect} />
60
64
  </PopoverContent>
61
65
  </Popover>
62
66
  </div>
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Parse a date-only string ("YYYY-MM-DD") as a local calendar day instead of
3
+ * UTC midnight, so negative-UTC-offset timezones don't render the previous day.
4
+ * Accepts a `Date` (returned as-is when valid) and returns `undefined` for
5
+ * null/undefined/invalid input.
6
+ */
7
+ export function parseDateInput(value: Date | string | null | undefined): Date | undefined {
8
+ if (!value) return undefined;
9
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value;
10
+ const [year, month, day] = value.split("-").map(Number);
11
+ if (!year || !month || !day) return undefined;
12
+ const date = new Date(year, month - 1, day);
13
+ return Number.isNaN(date.getTime()) ? undefined : date;
14
+ }
15
+
16
+ /**
17
+ * Serialize a date to its "YYYY-MM-DD" form using local components, so the
18
+ * value round-trips without a UTC shift. Returns "" for invalid input.
19
+ */
20
+ export function formatDateInput(value: Date | string | null | undefined): string {
21
+ const date = parseDateInput(value);
22
+ if (!date) return "";
23
+ const year = date.getFullYear();
24
+ const month = String(date.getMonth() + 1).padStart(2, "0");
25
+ const day = String(date.getDate()).padStart(2, "0");
26
+ return `${year}-${month}-${day}`;
27
+ }
28
+
29
+ /** Today's date as a "YYYY-MM-DD" local string. */
30
+ export function todayDateInput(): string {
31
+ return formatDateInput(new Date());
32
+ }
@@ -2,6 +2,9 @@ export { FilterSelect } from "../filters/filter-select";
2
2
  export type { FilterSelectOption, FilterSelectProps } from "../filters/filter-select";
3
3
  export { Calendar } from "./calendar";
4
4
  export type { CalendarProps } from "./calendar";
5
+ export { DateField } from "./date-field";
6
+ export type { DateFieldProps } from "./date-field";
7
+ export { formatDateInput, parseDateInput, todayDateInput } from "./date-utils";
5
8
  export { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
6
9
  export type { DateMonthSelectProps } from "./date-month-select";
7
10
  export { DatePicker } from "./date-picker";
@@ -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
- /** True while rendering inside the mobile Sheet copy of the nav content. */
11
- const MobileMenuContext = React.createContext(false);
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}>{rest}</MobileMenuContext.Provider>
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
  >
@@ -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 className="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">
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>
@@ -18,6 +18,7 @@
18
18
  @import "./tokens/generated/light.css";
19
19
  @import "./tokens/generated/dark.css" (prefers-color-scheme: dark);
20
20
  @import "./animations.css";
21
+ @import "./nokia.css";
21
22
 
22
23
  @theme {
23
24
  --animate-accordion-down: accordion-down 200ms ease-out;
@@ -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
+ }