@open-mercato/ui 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7195.1.b5efd1d51a
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/.turbo/turbo-build.log +2 -2
- package/dist/backend/AppShell.js +66 -7
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +14 -8
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +108 -11
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/forms/ActionsDropdown.js +35 -5
- package/dist/backend/forms/ActionsDropdown.js.map +2 -2
- package/dist/backend/forms/index.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +6 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/spotIds.js +2 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/inputs/DurationInput.js +182 -0
- package/dist/backend/inputs/DurationInput.js.map +7 -0
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/index.js +1 -0
- package/dist/backend/inputs/index.js.map +2 -2
- package/dist/backend/nav/navBadges.js +38 -0
- package/dist/backend/nav/navBadges.js.map +7 -0
- package/dist/primitives/segmented-control.js +4 -4
- package/dist/primitives/segmented-control.js.map +1 -1
- package/dist/primitives/sheet.js +21 -11
- package/dist/primitives/sheet.js.map +2 -2
- package/dist/primitives/textarea.js +54 -4
- package/dist/primitives/textarea.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +106 -2
- package/src/backend/CrudForm.tsx +28 -9
- package/src/backend/DataTable.tsx +176 -22
- package/src/backend/__tests__/CrudForm.density.test.tsx +113 -0
- package/src/backend/__tests__/DataTable.propBulkActions.test.tsx +200 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +76 -0
- package/src/backend/__tests__/navBadges.test.ts +57 -0
- package/src/backend/forms/ActionsDropdown.tsx +73 -6
- package/src/backend/forms/index.ts +1 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +6 -0
- package/src/backend/injection/spotIds.ts +8 -0
- package/src/backend/inputs/DurationInput.tsx +207 -0
- package/src/backend/inputs/EventSelect.tsx +2 -0
- package/src/backend/inputs/__tests__/DurationInput.test.tsx +220 -0
- package/src/backend/inputs/index.ts +1 -0
- package/src/backend/nav/navBadges.ts +61 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +2 -2
- package/src/primitives/__tests__/textarea.test.tsx +50 -0
- package/src/primitives/segmented-control.tsx +7 -7
- package/src/primitives/sheet.tsx +7 -1
- package/src/primitives/textarea.tsx +77 -4
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
const badges = /* @__PURE__ */ new Map();
|
|
4
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
5
|
+
function emit() {
|
|
6
|
+
for (const listener of listeners) listener();
|
|
7
|
+
}
|
|
8
|
+
function setNavBadge(href, badge) {
|
|
9
|
+
if (!href) return;
|
|
10
|
+
if (!badge || !Number.isFinite(badge.count) || badge.count <= 0) {
|
|
11
|
+
if (badges.delete(href)) emit();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const current = badges.get(href);
|
|
15
|
+
if (current && current.count === badge.count && current.tone === badge.tone && current.label === badge.label) return;
|
|
16
|
+
badges.set(href, badge);
|
|
17
|
+
emit();
|
|
18
|
+
}
|
|
19
|
+
function clearNavBadge(href) {
|
|
20
|
+
setNavBadge(href, null);
|
|
21
|
+
}
|
|
22
|
+
function subscribe(listener) {
|
|
23
|
+
listeners.add(listener);
|
|
24
|
+
return () => {
|
|
25
|
+
listeners.delete(listener);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function useNavBadge(href) {
|
|
29
|
+
const getSnapshot = React.useCallback(() => badges.get(href) ?? null, [href]);
|
|
30
|
+
const getServerSnapshot = React.useCallback(() => null, []);
|
|
31
|
+
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
clearNavBadge,
|
|
35
|
+
setNavBadge,
|
|
36
|
+
useNavBadge
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=navBadges.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/nav/navBadges.ts"],
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\n\n/**\n * Live counts rendered on sidebar nav items (\"3 waiting for you\").\n *\n * Why this is a client-side store rather than a field on the chrome payload:\n * `/api/auth/admin/nav` is cached for 30 minutes behind a module-surface\n * fingerprint, so a per-user count served through it would be both stale and\n * cache-poisoning. Core owns the capability and the rendering; whichever module\n * owns the number mounts a headless widget into `shell:sidebar:badges` and\n * publishes here.\n *\n * Counts are advisory: a nav item renders exactly what it is given, and an\n * absent or zero count renders nothing at all \u2014 never a \"0\" chip.\n */\nexport type NavBadgeTone = 'default' | 'attention'\n\nexport type NavBadge = {\n count: number\n tone?: NavBadgeTone\n /** Accessible label; falls back to the raw count when omitted. */\n label?: string\n}\n\nconst badges = new Map<string, NavBadge>()\nconst listeners = new Set<() => void>()\n\nfunction emit(): void {\n for (const listener of listeners) listener()\n}\n\nexport function setNavBadge(href: string, badge: NavBadge | null): void {\n if (!href) return\n if (!badge || !Number.isFinite(badge.count) || badge.count <= 0) {\n if (badges.delete(href)) emit()\n return\n }\n const current = badges.get(href)\n if (current && current.count === badge.count && current.tone === badge.tone && current.label === badge.label) return\n badges.set(href, badge)\n emit()\n}\n\nexport function clearNavBadge(href: string): void {\n setNavBadge(href, null)\n}\n\nfunction subscribe(listener: () => void): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n}\n\nexport function useNavBadge(href: string): NavBadge | null {\n const getSnapshot = React.useCallback(() => badges.get(href) ?? null, [href])\n const getServerSnapshot = React.useCallback(() => null, [])\n return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)\n}\n"],
|
|
5
|
+
"mappings": ";AAEA,YAAY,WAAW;AAwBvB,MAAM,SAAS,oBAAI,IAAsB;AACzC,MAAM,YAAY,oBAAI,IAAgB;AAEtC,SAAS,OAAa;AACpB,aAAW,YAAY,UAAW,UAAS;AAC7C;AAEO,SAAS,YAAY,MAAc,OAA8B;AACtE,MAAI,CAAC,KAAM;AACX,MAAI,CAAC,SAAS,CAAC,OAAO,SAAS,MAAM,KAAK,KAAK,MAAM,SAAS,GAAG;AAC/D,QAAI,OAAO,OAAO,IAAI,EAAG,MAAK;AAC9B;AAAA,EACF;AACA,QAAM,UAAU,OAAO,IAAI,IAAI;AAC/B,MAAI,WAAW,QAAQ,UAAU,MAAM,SAAS,QAAQ,SAAS,MAAM,QAAQ,QAAQ,UAAU,MAAM,MAAO;AAC9G,SAAO,IAAI,MAAM,KAAK;AACtB,OAAK;AACP;AAEO,SAAS,cAAc,MAAoB;AAChD,cAAY,MAAM,IAAI;AACxB;AAEA,SAAS,UAAU,UAAkC;AACnD,YAAU,IAAI,QAAQ;AACtB,SAAO,MAAM;AACX,cAAU,OAAO,QAAQ;AAAA,EAC3B;AACF;AAEO,SAAS,YAAY,MAA+B;AACzD,QAAM,cAAc,MAAM,YAAY,MAAM,OAAO,IAAI,IAAI,KAAK,MAAM,CAAC,IAAI,CAAC;AAC5E,QAAM,oBAAoB,MAAM,YAAY,MAAM,MAAM,CAAC,CAAC;AAC1D,SAAO,MAAM,qBAAqB,WAAW,aAAa,iBAAiB;AAC7E;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -9,8 +9,8 @@ const SegmentedControlContext = React.createContext({
|
|
|
9
9
|
disabled: false
|
|
10
10
|
});
|
|
11
11
|
const trackVariants = cva(
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// Rounded-rectangle track (DS Open Mercato node 2604:114) with subtle inner
|
|
13
|
+
// padding so the selected item renders as a smaller inner card. Track is muted —
|
|
14
14
|
// selected item raises with bg-background + shadow-sm. We use full
|
|
15
15
|
// `bg-muted` (not /40) so the contrast between track and a selected
|
|
16
16
|
// bg-background item stays visible in the light theme; in dark mode
|
|
@@ -21,7 +21,7 @@ const trackVariants = cva(
|
|
|
21
21
|
// sm → track h-7 (28px) − 2px border − 2px padding (p-px ×2) = 24px → matches item h-6
|
|
22
22
|
// Using `p-0.5` (2px each side = 4px total) instead breaks both sizes by
|
|
23
23
|
// 2px so the selected pill clips top and bottom against the track border.
|
|
24
|
-
"inline-flex w-fit gap-0 rounded-
|
|
24
|
+
"inline-flex w-fit gap-0 rounded-lg border border-input bg-muted p-px transition-colors",
|
|
25
25
|
{
|
|
26
26
|
variants: {
|
|
27
27
|
size: {
|
|
@@ -46,7 +46,7 @@ const itemVariants = cva(
|
|
|
46
46
|
// shadow, important when the surrounding theme is high-key and the
|
|
47
47
|
// shadow alone is hard to read). Unselected text is muted; hover
|
|
48
48
|
// only nudges color (no bg change — keeps the track flat).
|
|
49
|
-
"inline-flex items-center justify-center rounded-
|
|
49
|
+
"inline-flex items-center justify-center rounded-md font-medium transition-all outline-none focus-visible:shadow-focus disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:font-semibold data-[state=checked]:shadow-sm data-[state=unchecked]:bg-transparent data-[state=unchecked]:text-muted-foreground data-[state=unchecked]:hover:text-foreground",
|
|
50
50
|
{
|
|
51
51
|
variants: {
|
|
52
52
|
size: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/primitives/segmented-control.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport * as RadioGroupPrimitive from '@radix-ui/react-radio-group'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\n\n/**\n *
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport * as RadioGroupPrimitive from '@radix-ui/react-radio-group'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\n\n/**\n * Segmented control per Figma DS Open Mercato (node `2604:114`): a muted\n * rounded-rectangle track with a raised white card for the selected segment.\n * Renders a single track with N items where exactly one is selected at a time. Selecting\n * a new item fires `onValueChange`.\n *\n * Use for **mutually-exclusive view state** \u2014 list page filters like\n * \"All / Active / Archived\", chart period selectors, layout toggles\n * (List / Grid). For *related actions* (each does something different),\n * reach for `ButtonGroup` instead.\n *\n * Built on Radix `RadioGroup` so we inherit the radio-group ARIA contract\n * (`role=\"radiogroup\"`, `role=\"radio\"` on items, arrow-key navigation,\n * roving tabindex) for free. No new dependency \u2014 Radix RadioGroup is\n * already installed via the `Radio` primitive.\n *\n * ```tsx\n * const [view, setView] = React.useState('all')\n * <SegmentedControl value={view} onValueChange={setView} aria-label=\"View filter\">\n * <SegmentedControlItem value=\"all\">All</SegmentedControlItem>\n * <SegmentedControlItem value=\"active\">Active</SegmentedControlItem>\n * <SegmentedControlItem value=\"archived\">Archived</SegmentedControlItem>\n * </SegmentedControl>\n * ```\n *\n * Sizes:\n * - `default` (h-8 / 32px) \u2014 standard toolbar density.\n * - `sm` (h-7 / 28px) \u2014 tighter; pair with `text-xs`.\n */\n\ntype SegmentedControlContextValue = {\n size: 'sm' | 'default'\n disabled?: boolean\n}\n\nconst SegmentedControlContext = React.createContext<SegmentedControlContextValue>({\n size: 'default',\n disabled: false,\n})\n\nconst trackVariants = cva(\n // Rounded-rectangle track (DS Open Mercato node 2604:114) with subtle inner\n // padding so the selected item renders as a smaller inner card. Track is muted \u2014\n // selected item raises with bg-background + shadow-sm. We use full\n // `bg-muted` (not /40) so the contrast between track and a selected\n // bg-background item stays visible in the light theme; in dark mode\n // the token already darkens further so the contrast holds.\n //\n // Height math (box-border on every element):\n // default \u2192 track h-8 (32px) \u2212 2px border \u2212 2px padding (p-px \u00D72) = 28px \u2192 matches item h-7\n // sm \u2192 track h-7 (28px) \u2212 2px border \u2212 2px padding (p-px \u00D72) = 24px \u2192 matches item h-6\n // Using `p-0.5` (2px each side = 4px total) instead breaks both sizes by\n // 2px so the selected pill clips top and bottom against the track border.\n 'inline-flex w-fit gap-0 rounded-lg border border-input bg-muted p-px transition-colors',\n {\n variants: {\n size: {\n sm: 'h-7',\n default: 'h-8',\n },\n disabled: {\n true: 'cursor-not-allowed opacity-60',\n false: '',\n },\n },\n defaultVariants: {\n size: 'default',\n disabled: false,\n },\n },\n)\n\nconst itemVariants = cva(\n // Items are pills that fill the track minus its 2px inner padding.\n // Selected state lifts via bg-background + shadow-sm + font-semibold\n // (the font-weight bump gives a secondary visual cue beyond color +\n // shadow, important when the surrounding theme is high-key and the\n // shadow alone is hard to read). Unselected text is muted; hover\n // only nudges color (no bg change \u2014 keeps the track flat).\n 'inline-flex items-center justify-center rounded-md font-medium ' +\n 'transition-all outline-none focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-50 ' +\n 'data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:font-semibold data-[state=checked]:shadow-sm ' +\n 'data-[state=unchecked]:bg-transparent data-[state=unchecked]:text-muted-foreground data-[state=unchecked]:hover:text-foreground',\n {\n variants: {\n size: {\n sm: 'h-6 px-2.5 text-xs',\n default: 'h-7 px-3 text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n },\n)\n\nexport type SegmentedControlProps = Omit<\n React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>,\n 'orientation'\n> &\n VariantProps<typeof trackVariants> & {\n /** Optional screen-reader label for the radio group. */\n 'aria-label'?: string\n }\n\nexport const SegmentedControl = React.forwardRef<\n React.ElementRef<typeof RadioGroupPrimitive.Root>,\n SegmentedControlProps\n>(({ className, size, disabled, children, ...props }, ref) => {\n const ctx = React.useMemo<SegmentedControlContextValue>(\n () => ({ size: size ?? 'default', disabled: disabled ?? false }),\n [size, disabled],\n )\n return (\n <SegmentedControlContext.Provider value={ctx}>\n <RadioGroupPrimitive.Root\n ref={ref}\n orientation=\"horizontal\"\n disabled={disabled ?? undefined}\n data-slot=\"segmented-control\"\n className={cn(trackVariants({ size, disabled }), className)}\n {...props}\n >\n {children}\n </RadioGroupPrimitive.Root>\n </SegmentedControlContext.Provider>\n )\n})\nSegmentedControl.displayName = 'SegmentedControl'\n\nexport type SegmentedControlItemProps = React.ComponentPropsWithoutRef<\n typeof RadioGroupPrimitive.Item\n>\n\nexport const SegmentedControlItem = React.forwardRef<\n React.ElementRef<typeof RadioGroupPrimitive.Item>,\n SegmentedControlItemProps\n>(({ className, children, ...props }, ref) => {\n const { size } = React.useContext(SegmentedControlContext)\n return (\n <RadioGroupPrimitive.Item\n ref={ref}\n data-slot=\"segmented-control-item\"\n className={cn(itemVariants({ size }), className)}\n {...props}\n >\n {children}\n </RadioGroupPrimitive.Item>\n )\n})\nSegmentedControlItem.displayName = 'SegmentedControlItem'\n\nexport { trackVariants as segmentedControlTrackVariants, itemVariants as segmentedControlItemVariants }\n"],
|
|
5
5
|
"mappings": ";AA4HM;AA1HN,YAAY,WAAW;AACvB,YAAY,yBAAyB;AACrC,SAAS,WAA8B;AAEvC,SAAS,UAAU;AAqCnB,MAAM,0BAA0B,MAAM,cAA4C;AAAA,EAChF,MAAM;AAAA,EACN,UAAU;AACZ,CAAC;AAED,MAAM,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAapB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ,SAAS;AAAA,MACX;AAAA,MACA,UAAU;AAAA,QACR,MAAM;AAAA,QACN,OAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,MAAM;AAAA,MACN,UAAU;AAAA,IACZ;AAAA,EACF;AACF;AAEA,MAAM,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOnB;AAAA,EAKA;AAAA,IACE,UAAU;AAAA,MACR,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ,SAAS;AAAA,MACX;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,MAAM;AAAA,IACR;AAAA,EACF;AACF;AAWO,MAAM,mBAAmB,MAAM,WAGpC,CAAC,EAAE,WAAW,MAAM,UAAU,UAAU,GAAG,MAAM,GAAG,QAAQ;AAC5D,QAAM,MAAM,MAAM;AAAA,IAChB,OAAO,EAAE,MAAM,QAAQ,WAAW,UAAU,YAAY,MAAM;AAAA,IAC9D,CAAC,MAAM,QAAQ;AAAA,EACjB;AACA,SACE,oBAAC,wBAAwB,UAAxB,EAAiC,OAAO,KACvC;AAAA,IAAC,oBAAoB;AAAA,IAApB;AAAA,MACC;AAAA,MACA,aAAY;AAAA,MACZ,UAAU,YAAY;AAAA,MACtB,aAAU;AAAA,MACV,WAAW,GAAG,cAAc,EAAE,MAAM,SAAS,CAAC,GAAG,SAAS;AAAA,MACzD,GAAG;AAAA,MAEH;AAAA;AAAA,EACH,GACF;AAEJ,CAAC;AACD,iBAAiB,cAAc;AAMxB,MAAM,uBAAuB,MAAM,WAGxC,CAAC,EAAE,WAAW,UAAU,GAAG,MAAM,GAAG,QAAQ;AAC5C,QAAM,EAAE,KAAK,IAAI,MAAM,WAAW,uBAAuB;AACzD,SACE;AAAA,IAAC,oBAAoB;AAAA,IAApB;AAAA,MACC;AAAA,MACA,aAAU;AAAA,MACV,WAAW,GAAG,aAAa,EAAE,KAAK,CAAC,GAAG,SAAS;AAAA,MAC9C,GAAG;AAAA,MAEH;AAAA;AAAA,EACH;AAEJ,CAAC;AACD,qBAAqB,cAAc;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/primitives/sheet.js
CHANGED
|
@@ -12,6 +12,7 @@ const SheetPortal = DialogPrimitive.Portal;
|
|
|
12
12
|
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
|
|
13
13
|
DialogPrimitive.Overlay,
|
|
14
14
|
{
|
|
15
|
+
"data-slot": "sheet-overlay",
|
|
15
16
|
className: cn(
|
|
16
17
|
"fixed inset-x-0 bottom-0 top-[var(--topbar-height,0px)] z-overlay bg-black/40 backdrop-blur-sm",
|
|
17
18
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
@@ -45,17 +46,26 @@ const sheetVariants = cva(
|
|
|
45
46
|
);
|
|
46
47
|
const SheetContent = React.forwardRef(({ side = "right", className, children, hideClose = false, closeLabel = "Close", ...props }, ref) => /* @__PURE__ */ jsxs(SheetPortal, { children: [
|
|
47
48
|
/* @__PURE__ */ jsx(SheetOverlay, {}),
|
|
48
|
-
/* @__PURE__ */ jsxs(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
/* @__PURE__ */ jsxs(
|
|
50
|
+
DialogPrimitive.Content,
|
|
51
|
+
{
|
|
52
|
+
ref,
|
|
53
|
+
"data-slot": "sheet-content",
|
|
54
|
+
className: cn(sheetVariants({ side }), className),
|
|
55
|
+
...props,
|
|
56
|
+
children: [
|
|
57
|
+
children,
|
|
58
|
+
!hideClose ? /* @__PURE__ */ jsx(
|
|
59
|
+
DialogPrimitive.Close,
|
|
60
|
+
{
|
|
61
|
+
className: "absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus-visible:shadow-focus",
|
|
62
|
+
"aria-label": closeLabel,
|
|
63
|
+
children: /* @__PURE__ */ jsx(X, { className: "size-4", "aria-hidden": "true" })
|
|
64
|
+
}
|
|
65
|
+
) : null
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
)
|
|
59
69
|
] }));
|
|
60
70
|
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
|
61
71
|
const SheetHeader = React.forwardRef(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/primitives/sheet.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport * as DialogPrimitive from '@radix-ui/react-dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { X } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst Sheet = DialogPrimitive.Root\n\nconst SheetTrigger = DialogPrimitive.Trigger\n\nconst SheetClose = DialogPrimitive.Close\n\nconst SheetPortal = DialogPrimitive.Portal\n\nconst SheetOverlay = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Overlay>,\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n <DialogPrimitive.Overlay\n className={cn(\n 'fixed inset-x-0 bottom-0 top-[var(--topbar-height,0px)] z-overlay bg-black/40 backdrop-blur-sm',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n className,\n )}\n {...props}\n ref={ref}\n />\n))\nSheetOverlay.displayName = DialogPrimitive.Overlay.displayName\n\nconst sheetVariants = cva(\n cn(\n 'fixed z-modal flex flex-col gap-4 bg-background shadow-lg transition ease-in-out',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:duration-200 data-[state=open]:duration-300',\n ),\n {\n variants: {\n side: {\n top:\n 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',\n bottom:\n 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',\n left:\n 'top-[var(--topbar-height,0px)] bottom-0 left-0 w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-md',\n right:\n 'top-[var(--topbar-height,0px)] bottom-0 right-0 w-full sm:max-w-md border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',\n },\n },\n defaultVariants: {\n side: 'right',\n },\n }\n)\n\nexport type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &\n VariantProps<typeof sheetVariants> & {\n /** Hide the built-in close (X) button \u2014 useful when the consumer renders its own close affordance. */\n hideClose?: boolean\n /** Override the aria-label for the built-in close button. */\n closeLabel?: string\n }\n\n/**\n * SheetContent \u2014 side-anchored Radix Dialog wrapper.\n *\n * Topbar integration: the `left` and `right` variants anchor at\n * `top: var(--topbar-height, 0px)` so a sticky app topbar stays visible above\n * the panel. Consumers that render inside `AppShell` get the correct offset\n * because the shell sets `--topbar-height` on the outer container. Surfaces\n * outside the shell (portal modals, marketing pages, standalone embeds) keep\n * the default `0px` fallback and align to the viewport top \u2014 that is the\n * correct behavior when there is no topbar to clear.\n *\n * To opt into the offset from your own layout, set the CSS variable on any\n * ancestor of `<SheetContent>`:\n *\n * ```css\n * .my-shell { --topbar-height: 60px; }\n * ```\n */\n\nconst SheetContent = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Content>,\n SheetContentProps\n>(({ side = 'right', className, children, hideClose = false, closeLabel = 'Close', ...props }, ref) => (\n <SheetPortal>\n <SheetOverlay />\n <DialogPrimitive.Content
|
|
5
|
-
"mappings": ";AAoBE,
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport * as DialogPrimitive from '@radix-ui/react-dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { X } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst Sheet = DialogPrimitive.Root\n\nconst SheetTrigger = DialogPrimitive.Trigger\n\nconst SheetClose = DialogPrimitive.Close\n\nconst SheetPortal = DialogPrimitive.Portal\n\nconst SheetOverlay = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Overlay>,\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n <DialogPrimitive.Overlay\n data-slot=\"sheet-overlay\"\n className={cn(\n 'fixed inset-x-0 bottom-0 top-[var(--topbar-height,0px)] z-overlay bg-black/40 backdrop-blur-sm',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n className,\n )}\n {...props}\n ref={ref}\n />\n))\nSheetOverlay.displayName = DialogPrimitive.Overlay.displayName\n\nconst sheetVariants = cva(\n cn(\n 'fixed z-modal flex flex-col gap-4 bg-background shadow-lg transition ease-in-out',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:duration-200 data-[state=open]:duration-300',\n ),\n {\n variants: {\n side: {\n top:\n 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',\n bottom:\n 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',\n left:\n 'top-[var(--topbar-height,0px)] bottom-0 left-0 w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-md',\n right:\n 'top-[var(--topbar-height,0px)] bottom-0 right-0 w-full sm:max-w-md border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',\n },\n },\n defaultVariants: {\n side: 'right',\n },\n }\n)\n\nexport type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &\n VariantProps<typeof sheetVariants> & {\n /** Hide the built-in close (X) button \u2014 useful when the consumer renders its own close affordance. */\n hideClose?: boolean\n /** Override the aria-label for the built-in close button. */\n closeLabel?: string\n }\n\n/**\n * SheetContent \u2014 side-anchored Radix Dialog wrapper.\n *\n * Topbar integration: the `left` and `right` variants anchor at\n * `top: var(--topbar-height, 0px)` so a sticky app topbar stays visible above\n * the panel. Consumers that render inside `AppShell` get the correct offset\n * because the shell sets `--topbar-height` on the outer container. Surfaces\n * outside the shell (portal modals, marketing pages, standalone embeds) keep\n * the default `0px` fallback and align to the viewport top \u2014 that is the\n * correct behavior when there is no topbar to clear.\n *\n * To opt into the offset from your own layout, set the CSS variable on any\n * ancestor of `<SheetContent>`:\n *\n * ```css\n * .my-shell { --topbar-height: 60px; }\n * ```\n */\n\nconst SheetContent = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Content>,\n SheetContentProps\n>(({ side = 'right', className, children, hideClose = false, closeLabel = 'Close', ...props }, ref) => (\n <SheetPortal>\n <SheetOverlay />\n <DialogPrimitive.Content\n ref={ref}\n data-slot=\"sheet-content\"\n className={cn(sheetVariants({ side }), className)}\n {...props}\n >\n {children}\n {!hideClose ? (\n <DialogPrimitive.Close\n className=\"absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus-visible:shadow-focus\"\n aria-label={closeLabel}\n >\n <X className=\"size-4\" aria-hidden=\"true\" />\n </DialogPrimitive.Close>\n ) : null}\n </DialogPrimitive.Content>\n </SheetPortal>\n))\nSheetContent.displayName = DialogPrimitive.Content.displayName\n\nconst SheetHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn('flex flex-col gap-1.5 border-b px-4 py-3 pr-12 text-left', className)}\n {...props}\n />\n ),\n)\nSheetHeader.displayName = 'SheetHeader'\n\nconst SheetFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div\n ref={ref}\n className={cn('flex flex-col-reverse gap-2 border-t px-4 py-3 sm:flex-row sm:justify-end', className)}\n {...props}\n />\n ),\n)\nSheetFooter.displayName = 'SheetFooter'\n\nconst SheetTitle = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Title>,\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n <DialogPrimitive.Title\n ref={ref}\n className={cn('text-base font-semibold text-foreground', className)}\n {...props}\n />\n))\nSheetTitle.displayName = DialogPrimitive.Title.displayName\n\nconst SheetDescription = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Description>,\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n <DialogPrimitive.Description\n ref={ref}\n className={cn('text-sm text-muted-foreground', className)}\n {...props}\n />\n))\nSheetDescription.displayName = DialogPrimitive.Description.displayName\n\nexport {\n Sheet,\n SheetClose,\n SheetContent,\n SheetDescription,\n SheetFooter,\n SheetHeader,\n SheetOverlay,\n SheetPortal,\n SheetTitle,\n SheetTrigger,\n sheetVariants,\n}\n"],
|
|
5
|
+
"mappings": ";AAoBE,cAwEE,YAxEF;AAlBF,YAAY,WAAW;AACvB,YAAY,qBAAqB;AACjC,SAAS,WAA8B;AACvC,SAAS,SAAS;AAClB,SAAS,UAAU;AAEnB,MAAM,QAAQ,gBAAgB;AAE9B,MAAM,eAAe,gBAAgB;AAErC,MAAM,aAAa,gBAAgB;AAEnC,MAAM,cAAc,gBAAgB;AAEpC,MAAM,eAAe,MAAM,WAGzB,CAAC,EAAE,WAAW,GAAG,MAAM,GAAG,QAC1B;AAAA,EAAC,gBAAgB;AAAA,EAAhB;AAAA,IACC,aAAU;AAAA,IACV,WAAW;AAAA,MACT;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,IACC,GAAG;AAAA,IACJ;AAAA;AACF,CACD;AACD,aAAa,cAAc,gBAAgB,QAAQ;AAEnD,MAAM,gBAAgB;AAAA,EACpB;AAAA,IACE;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,MAAM;AAAA,QACJ,KACE;AAAA,QACF,QACE;AAAA,QACF,MACE;AAAA,QACF,OACE;AAAA,MACJ;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,MAAM;AAAA,IACR;AAAA,EACF;AACF;AA6BA,MAAM,eAAe,MAAM,WAGzB,CAAC,EAAE,OAAO,SAAS,WAAW,UAAU,YAAY,OAAO,aAAa,SAAS,GAAG,MAAM,GAAG,QAC7F,qBAAC,eACC;AAAA,sBAAC,gBAAa;AAAA,EACd;AAAA,IAAC,gBAAgB;AAAA,IAAhB;AAAA,MACC;AAAA,MACA,aAAU;AAAA,MACV,WAAW,GAAG,cAAc,EAAE,KAAK,CAAC,GAAG,SAAS;AAAA,MAC/C,GAAG;AAAA,MAEH;AAAA;AAAA,QACA,CAAC,YACA;AAAA,UAAC,gBAAgB;AAAA,UAAhB;AAAA,YACC,WAAU;AAAA,YACV,cAAY;AAAA,YAEZ,8BAAC,KAAE,WAAU,UAAS,eAAY,QAAO;AAAA;AAAA,QAC3C,IACE;AAAA;AAAA;AAAA,EACN;AAAA,GACF,CACD;AACD,aAAa,cAAc,gBAAgB,QAAQ;AAEnD,MAAM,cAAc,MAAM;AAAA,EACxB,CAAC,EAAE,WAAW,GAAG,MAAM,GAAG,QACxB;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,WAAW,GAAG,4DAA4D,SAAS;AAAA,MAClF,GAAG;AAAA;AAAA,EACN;AAEJ;AACA,YAAY,cAAc;AAE1B,MAAM,cAAc,MAAM;AAAA,EACxB,CAAC,EAAE,WAAW,GAAG,MAAM,GAAG,QACxB;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,WAAW,GAAG,6EAA6E,SAAS;AAAA,MACnG,GAAG;AAAA;AAAA,EACN;AAEJ;AACA,YAAY,cAAc;AAE1B,MAAM,aAAa,MAAM,WAGvB,CAAC,EAAE,WAAW,GAAG,MAAM,GAAG,QAC1B;AAAA,EAAC,gBAAgB;AAAA,EAAhB;AAAA,IACC;AAAA,IACA,WAAW,GAAG,2CAA2C,SAAS;AAAA,IACjE,GAAG;AAAA;AACN,CACD;AACD,WAAW,cAAc,gBAAgB,MAAM;AAE/C,MAAM,mBAAmB,MAAM,WAG7B,CAAC,EAAE,WAAW,GAAG,MAAM,GAAG,QAC1B;AAAA,EAAC,gBAAgB;AAAA,EAAhB;AAAA,IACC;AAAA,IACA,WAAW,GAAG,iCAAiC,SAAS;AAAA,IACvD,GAAG;AAAA;AACN,CACD;AACD,iBAAiB,cAAc,gBAAgB,YAAY;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,29 +3,79 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "@open-mercato/shared/lib/utils";
|
|
5
5
|
const baseTextareaClass = "flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-colors placeholder:text-muted-foreground outline-none focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground hover:bg-muted/40 disabled:cursor-not-allowed disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive resize-y min-h-[80px]";
|
|
6
|
+
const DEFAULT_AUTO_RESIZE_MAX_ROWS = 12;
|
|
7
|
+
const FALLBACK_LINE_HEIGHT_PX = 20;
|
|
8
|
+
function readPixels(value) {
|
|
9
|
+
const parsed = Number.parseFloat(value);
|
|
10
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
11
|
+
}
|
|
12
|
+
function applyAutoResize(element, maxRows) {
|
|
13
|
+
if (!element) return;
|
|
14
|
+
const computed = typeof window !== "undefined" ? window.getComputedStyle(element) : null;
|
|
15
|
+
const lineHeight = computed ? readPixels(computed.lineHeight) || FALLBACK_LINE_HEIGHT_PX : FALLBACK_LINE_HEIGHT_PX;
|
|
16
|
+
const padding = computed ? readPixels(computed.paddingTop) + readPixels(computed.paddingBottom) : 0;
|
|
17
|
+
const border = computed ? readPixels(computed.borderTopWidth) + readPixels(computed.borderBottomWidth) : 0;
|
|
18
|
+
const maxContentHeight = lineHeight * Math.max(1, maxRows) + padding;
|
|
19
|
+
element.style.height = "auto";
|
|
20
|
+
const contentHeight = element.scrollHeight;
|
|
21
|
+
const cappedHeight = Math.min(contentHeight, maxContentHeight);
|
|
22
|
+
element.style.height = `${cappedHeight + border}px`;
|
|
23
|
+
element.style.overflowY = contentHeight > maxContentHeight ? "auto" : "hidden";
|
|
24
|
+
}
|
|
6
25
|
const Textarea = React.forwardRef(
|
|
7
|
-
({
|
|
26
|
+
({
|
|
27
|
+
className,
|
|
28
|
+
showCount,
|
|
29
|
+
wrapperClassName,
|
|
30
|
+
autoResize,
|
|
31
|
+
maxRows = DEFAULT_AUTO_RESIZE_MAX_ROWS,
|
|
32
|
+
value,
|
|
33
|
+
defaultValue,
|
|
34
|
+
maxLength,
|
|
35
|
+
onChange,
|
|
36
|
+
...props
|
|
37
|
+
}, ref) => {
|
|
8
38
|
const [internalValue, setInternalValue] = React.useState(
|
|
9
39
|
typeof defaultValue === "string" ? defaultValue : typeof value === "string" ? value : ""
|
|
10
40
|
);
|
|
11
41
|
const isControlled = value !== void 0;
|
|
12
42
|
const currentValue = isControlled ? String(value ?? "") : internalValue;
|
|
43
|
+
const elementRef = React.useRef(null);
|
|
44
|
+
const setElementRef = React.useCallback(
|
|
45
|
+
(node) => {
|
|
46
|
+
elementRef.current = node;
|
|
47
|
+
if (typeof ref === "function") ref(node);
|
|
48
|
+
else if (ref) ref.current = node;
|
|
49
|
+
},
|
|
50
|
+
[ref]
|
|
51
|
+
);
|
|
52
|
+
React.useLayoutEffect(() => {
|
|
53
|
+
if (!autoResize) return;
|
|
54
|
+
applyAutoResize(elementRef.current, maxRows);
|
|
55
|
+
}, [autoResize, maxRows, currentValue]);
|
|
56
|
+
React.useEffect(() => {
|
|
57
|
+
if (!autoResize || typeof window === "undefined") return;
|
|
58
|
+
const onWindowResize = () => applyAutoResize(elementRef.current, maxRows);
|
|
59
|
+
window.addEventListener("resize", onWindowResize);
|
|
60
|
+
return () => window.removeEventListener("resize", onWindowResize);
|
|
61
|
+
}, [autoResize, maxRows]);
|
|
13
62
|
const handleChange = React.useCallback(
|
|
14
63
|
(event) => {
|
|
15
64
|
if (!isControlled) setInternalValue(event.target.value);
|
|
65
|
+
if (autoResize) applyAutoResize(event.currentTarget, maxRows);
|
|
16
66
|
onChange?.(event);
|
|
17
67
|
},
|
|
18
|
-
[isControlled, onChange]
|
|
68
|
+
[isControlled, onChange, autoResize, maxRows]
|
|
19
69
|
);
|
|
20
70
|
const textarea = /* @__PURE__ */ jsx(
|
|
21
71
|
"textarea",
|
|
22
72
|
{
|
|
23
|
-
ref,
|
|
73
|
+
ref: setElementRef,
|
|
24
74
|
value,
|
|
25
75
|
defaultValue: isControlled ? void 0 : defaultValue,
|
|
26
76
|
maxLength,
|
|
27
77
|
onChange: handleChange,
|
|
28
|
-
className: cn(baseTextareaClass, className),
|
|
78
|
+
className: cn(baseTextareaClass, autoResize && "resize-none overflow-hidden", className),
|
|
29
79
|
...props
|
|
30
80
|
}
|
|
31
81
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/primitives/textarea.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst baseTextareaClass =\n 'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-colors placeholder:text-muted-foreground outline-none focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground hover:bg-muted/40 disabled:cursor-not-allowed disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive resize-y min-h-[80px]'\n\nexport type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {\n /** Show character counter (`current/max`) below the textarea. Requires `maxLength`. */\n showCount?: boolean\n /** Optional className applied to the outer wrapper (when counter is shown). */\n wrapperClassName?: string\n}\n\nexport const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n ({
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst baseTextareaClass =\n 'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-colors placeholder:text-muted-foreground outline-none focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground hover:bg-muted/40 disabled:cursor-not-allowed disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive resize-y min-h-[80px]'\n\nconst DEFAULT_AUTO_RESIZE_MAX_ROWS = 12\nconst FALLBACK_LINE_HEIGHT_PX = 20\n\nfunction readPixels(value: string): number {\n const parsed = Number.parseFloat(value)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\n/**\n * Grow a textarea to fit its content, capped at `maxRows` rendered lines.\n * The cap is expressed in rows rather than pixels so it follows the element's\n * own type scale instead of a magic height, and the element only scrolls once\n * the cap is reached.\n */\nfunction applyAutoResize(element: HTMLTextAreaElement | null, maxRows: number): void {\n if (!element) return\n const computed = typeof window !== 'undefined' ? window.getComputedStyle(element) : null\n const lineHeight = computed ? readPixels(computed.lineHeight) || FALLBACK_LINE_HEIGHT_PX : FALLBACK_LINE_HEIGHT_PX\n const padding = computed ? readPixels(computed.paddingTop) + readPixels(computed.paddingBottom) : 0\n const border = computed ? readPixels(computed.borderTopWidth) + readPixels(computed.borderBottomWidth) : 0\n const maxContentHeight = lineHeight * Math.max(1, maxRows) + padding\n\n element.style.height = 'auto'\n const contentHeight = element.scrollHeight\n const cappedHeight = Math.min(contentHeight, maxContentHeight)\n element.style.height = `${cappedHeight + border}px`\n element.style.overflowY = contentHeight > maxContentHeight ? 'auto' : 'hidden'\n}\n\nexport type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {\n /** Show character counter (`current/max`) below the textarea. Requires `maxLength`. */\n showCount?: boolean\n /** Optional className applied to the outer wrapper (when counter is shown). */\n wrapperClassName?: string\n /**\n * Grow the field to fit its content instead of scrolling inside a fixed box.\n * Disables the manual resize handle \u2014 the height is owned by the content.\n */\n autoResize?: boolean\n /** Rows to grow to before the field starts scrolling. Only used with `autoResize`. */\n maxRows?: number\n}\n\nexport const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n (\n {\n className,\n showCount,\n wrapperClassName,\n autoResize,\n maxRows = DEFAULT_AUTO_RESIZE_MAX_ROWS,\n value,\n defaultValue,\n maxLength,\n onChange,\n ...props\n },\n ref\n ) => {\n const [internalValue, setInternalValue] = React.useState<string>(\n typeof defaultValue === 'string' ? defaultValue : typeof value === 'string' ? value : ''\n )\n\n const isControlled = value !== undefined\n const currentValue = isControlled ? String(value ?? '') : internalValue\n\n const elementRef = React.useRef<HTMLTextAreaElement | null>(null)\n const setElementRef = React.useCallback(\n (node: HTMLTextAreaElement | null) => {\n elementRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref) (ref as React.MutableRefObject<HTMLTextAreaElement | null>).current = node\n },\n [ref]\n )\n\n React.useLayoutEffect(() => {\n if (!autoResize) return\n applyAutoResize(elementRef.current, maxRows)\n }, [autoResize, maxRows, currentValue])\n\n React.useEffect(() => {\n if (!autoResize || typeof window === 'undefined') return\n const onWindowResize = () => applyAutoResize(elementRef.current, maxRows)\n window.addEventListener('resize', onWindowResize)\n return () => window.removeEventListener('resize', onWindowResize)\n }, [autoResize, maxRows])\n\n const handleChange = React.useCallback(\n (event: React.ChangeEvent<HTMLTextAreaElement>) => {\n if (!isControlled) setInternalValue(event.target.value)\n if (autoResize) applyAutoResize(event.currentTarget, maxRows)\n onChange?.(event)\n },\n [isControlled, onChange, autoResize, maxRows]\n )\n\n const textarea = (\n <textarea\n ref={setElementRef}\n value={value}\n defaultValue={isControlled ? undefined : defaultValue}\n maxLength={maxLength}\n onChange={handleChange}\n className={cn(baseTextareaClass, autoResize && 'resize-none overflow-hidden', className)}\n {...props}\n />\n )\n\n if (!showCount) return textarea\n\n const length = currentValue.length\n const max = typeof maxLength === 'number' ? maxLength : undefined\n const isError = max != null && length > max\n const isDisabled = props.disabled\n\n return (\n <div className={cn('flex flex-col gap-1', wrapperClassName)}>\n {textarea}\n <div className=\"flex justify-end\">\n <span\n className={cn(\n 'text-overline uppercase',\n isDisabled\n ? 'text-text-disabled'\n : isError\n ? 'text-destructive'\n : 'text-muted-foreground'\n )}\n aria-live=\"polite\"\n >\n {max != null ? `${length}/${max}` : `${length}`}\n </span>\n </div>\n </div>\n )\n }\n)\n\nTextarea.displayName = 'Textarea'\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7195.1.b5efd1d51a",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.7.1-develop.7195.1.b5efd1d51a",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.7.1-develop.7195.1.b5efd1d51a",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import { readVersionedPreference, writeVersionedPreference } from '@open-mercato
|
|
|
37
37
|
import { cloneSidebarGroups } from './sidebar/customization-helpers'
|
|
38
38
|
import type { SectionNavGroup } from './section-page/types'
|
|
39
39
|
import { InjectionSpot } from './injection/InjectionSpot'
|
|
40
|
+
import { useNavBadge } from './nav/navBadges'
|
|
40
41
|
import {
|
|
41
42
|
BackendRecordInjectionContextProvider,
|
|
42
43
|
type RecordInjectionContext,
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
BACKEND_TOPBAR_ACTIONS_INJECTION_SPOT_ID,
|
|
65
66
|
GLOBAL_HEADER_STATUS_INDICATORS_INJECTION_SPOT_ID,
|
|
66
67
|
GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID,
|
|
68
|
+
BACKEND_NAV_BADGES_INJECTION_SPOT_ID,
|
|
67
69
|
} from './injection/spotIds'
|
|
68
70
|
|
|
69
71
|
// Versioned-envelope discriminator for the persisted sidebar open/closed group
|
|
@@ -418,6 +420,32 @@ function sidebarBlockPadding(compact: boolean): string {
|
|
|
418
420
|
return `${compact ? 'px-2' : 'px-3'} transition-[padding] duration-200 ease-out`
|
|
419
421
|
}
|
|
420
422
|
|
|
423
|
+
/**
|
|
424
|
+
* The live count a module published for this nav item, or nothing at all.
|
|
425
|
+
*
|
|
426
|
+
* Zero and absent both render nothing: a "0" chip is noise dressed as
|
|
427
|
+
* information. Collapsed sidebars keep the chip — it is the only thing left
|
|
428
|
+
* saying something needs you.
|
|
429
|
+
*/
|
|
430
|
+
function NavItemBadge({ href, compact }: { href: string; compact: boolean }) {
|
|
431
|
+
const badge = useNavBadge(href)
|
|
432
|
+
if (!badge || badge.count <= 0) return null
|
|
433
|
+
const display = badge.count > 99 ? '99+' : String(badge.count)
|
|
434
|
+
const tone = badge.tone === 'attention'
|
|
435
|
+
? 'bg-status-warning-bg text-status-warning-text'
|
|
436
|
+
: 'bg-muted text-muted-foreground'
|
|
437
|
+
return (
|
|
438
|
+
<span
|
|
439
|
+
className={`ml-auto inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-medium ${tone} ${
|
|
440
|
+
compact ? 'absolute right-0 top-0 -mr-1 -mt-1' : ''
|
|
441
|
+
}`}
|
|
442
|
+
aria-label={badge.label ?? display}
|
|
443
|
+
>
|
|
444
|
+
{display}
|
|
445
|
+
</span>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
421
449
|
// An InjectionSpot renders nothing when no widget is registered, so its wrapper must collapse
|
|
422
450
|
// instead of surviving as an empty flex child that still costs a full `gap-3`.
|
|
423
451
|
function sidebarInjectionWrapper(compact: boolean): string {
|
|
@@ -448,6 +476,38 @@ const HeaderContext = createContext<{
|
|
|
448
476
|
setTitle: (t?: string) => void
|
|
449
477
|
} | null>(null)
|
|
450
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Additive contract that lets a page under the backend shell drive the desktop
|
|
481
|
+
* sidebar collapse without owning the shell. `requestCollapse(true)` forces the
|
|
482
|
+
* sidebar to render collapsed without overwriting the user's persisted manual
|
|
483
|
+
* preference; `releaseRequest()` drops the request and falls back to whatever
|
|
484
|
+
* the underlying state is (including any active settings/profile force-collapse).
|
|
485
|
+
*/
|
|
486
|
+
export type SidebarCollapseContextValue = {
|
|
487
|
+
collapsed: boolean
|
|
488
|
+
setCollapsed: (next: boolean) => void
|
|
489
|
+
requestCollapse: (next: boolean) => void
|
|
490
|
+
releaseRequest: () => void
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const noopSidebarCollapse: SidebarCollapseContextValue = {
|
|
494
|
+
collapsed: false,
|
|
495
|
+
setCollapsed: () => {},
|
|
496
|
+
requestCollapse: () => {},
|
|
497
|
+
releaseRequest: () => {},
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const SidebarCollapseContext = createContext<SidebarCollapseContextValue | null>(null)
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Read the backend shell's sidebar-collapse controls. Returns a safe no-op
|
|
504
|
+
* default when called outside `AppShell` so standalone renders and tests never
|
|
505
|
+
* crash.
|
|
506
|
+
*/
|
|
507
|
+
export function useSidebarCollapse(): SidebarCollapseContextValue {
|
|
508
|
+
return useContext(SidebarCollapseContext) ?? noopSidebarCollapse
|
|
509
|
+
}
|
|
510
|
+
|
|
451
511
|
export function ApplyBreadcrumb({ breadcrumb, title, titleKey }: { breadcrumb?: Array<{ label: string; href?: string; labelKey?: string }>; title?: string; titleKey?: string }) {
|
|
452
512
|
const ctx = useContext(HeaderContext)
|
|
453
513
|
const t = useT()
|
|
@@ -567,6 +627,17 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
567
627
|
}, [mobileOpen])
|
|
568
628
|
// Initialize from server-provided prop only to avoid hydration flicker
|
|
569
629
|
const [collapsed, setCollapsed] = React.useState(sidebarCollapsedDefault)
|
|
630
|
+
// External collapse request (additive). A page under the shell can force the
|
|
631
|
+
// sidebar collapsed via `useSidebarCollapse().requestCollapse(true)` without
|
|
632
|
+
// touching `collapsed` (the user's persisted manual preference) — so nothing
|
|
633
|
+
// is written to localStorage/cookie and `releaseRequest()` restores the prior
|
|
634
|
+
// underlying state. Default `null` means "no request", keeping behavior
|
|
635
|
+
// byte-for-byte identical when no page consumes the context.
|
|
636
|
+
const [externalCollapseRequest, setExternalCollapseRequest] = React.useState<boolean | null>(null)
|
|
637
|
+
// Remember the value the user manually had before an external request took
|
|
638
|
+
// over, mirroring `collapsedBeforeSectionRef`. Not required for correctness
|
|
639
|
+
// (the request never mutates `collapsed`) but keeps the intent explicit.
|
|
640
|
+
const collapsedBeforeExternalRequestRef = React.useRef<boolean | null>(null)
|
|
570
641
|
// Maintain internal nav state so we can augment it client-side
|
|
571
642
|
const [navGroups, setNavGroups] = React.useState(resolvedGroups)
|
|
572
643
|
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>(() =>
|
|
@@ -582,9 +653,29 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
582
653
|
if (!label) return false
|
|
583
654
|
return label.toLowerCase().includes(navQueryNorm)
|
|
584
655
|
}, [navQueryActive, navQueryNorm])
|
|
585
|
-
|
|
656
|
+
// An active external collapse request wins over the manual/section state
|
|
657
|
+
// ("collapsed wins"); when released (`null`) we fall back to `collapsed`,
|
|
658
|
+
// which already reflects any settings/profile force-collapse.
|
|
659
|
+
const effectiveCollapsed = externalCollapseRequest === true ? true : collapsed
|
|
586
660
|
const expandedSidebarWidth = '240px'
|
|
587
661
|
|
|
662
|
+
const requestCollapse = React.useCallback((next: boolean) => {
|
|
663
|
+
setExternalCollapseRequest((prev) => {
|
|
664
|
+
if (prev === null) collapsedBeforeExternalRequestRef.current = collapsed
|
|
665
|
+
return next
|
|
666
|
+
})
|
|
667
|
+
}, [collapsed])
|
|
668
|
+
const releaseRequest = React.useCallback(() => {
|
|
669
|
+
collapsedBeforeExternalRequestRef.current = null
|
|
670
|
+
setExternalCollapseRequest(null)
|
|
671
|
+
}, [])
|
|
672
|
+
const sidebarCollapseValue = React.useMemo<SidebarCollapseContextValue>(() => ({
|
|
673
|
+
collapsed: effectiveCollapsed,
|
|
674
|
+
setCollapsed: (next: boolean) => setCollapsed(next),
|
|
675
|
+
requestCollapse,
|
|
676
|
+
releaseRequest,
|
|
677
|
+
}), [effectiveCollapsed, requestCollapse, releaseRequest])
|
|
678
|
+
|
|
588
679
|
// Track scroll position of the desktop sidebar's inner scroll container so we can
|
|
589
680
|
// flip the affordance chevron between down/up (and hide it entirely when content
|
|
590
681
|
// fits without scrolling). The inner div is rendered deep in renderSidebar /
|
|
@@ -918,6 +1009,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
918
1009
|
)}
|
|
919
1010
|
</span>
|
|
920
1011
|
{!compact && <span className="truncate">{label}</span>}
|
|
1012
|
+
<NavItemBadge href={item.href} compact={compact} />
|
|
921
1013
|
</Link>
|
|
922
1014
|
{showChildren ? childItems.map((child) => renderSectionItem(child, depth + 1)) : null}
|
|
923
1015
|
</React.Fragment>
|
|
@@ -1320,9 +1412,19 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1320
1412
|
|
|
1321
1413
|
return (
|
|
1322
1414
|
<HeaderContext.Provider value={headerCtxValue}>
|
|
1415
|
+
<SidebarCollapseContext.Provider value={sidebarCollapseValue}>
|
|
1323
1416
|
<div
|
|
1324
1417
|
className={`relative min-h-svh lg:grid transition-[grid-template-columns] duration-200 ease-out ${gridColsClass}`}
|
|
1325
|
-
style={{
|
|
1418
|
+
style={{
|
|
1419
|
+
'--topbar-height': '61px',
|
|
1420
|
+
// Left offset of the content column (sidebar, plus the section sidebar on
|
|
1421
|
+
// settings/profile routes). Exposed so full-bleed pages — e.g. the
|
|
1422
|
+
// workflow visual editor — can size themselves to the content column with
|
|
1423
|
+
// `calc(100vw - var(--app-content-offset))` instead of being capped by
|
|
1424
|
+
// `main`'s centered max-width. Only meaningful at `lg` and up, where the
|
|
1425
|
+
// grid (and the sidebar) is active.
|
|
1426
|
+
'--app-content-offset': isSectionView ? `calc(${asideWidth} + 240px)` : asideWidth,
|
|
1427
|
+
} as React.CSSProperties}
|
|
1326
1428
|
>
|
|
1327
1429
|
<a
|
|
1328
1430
|
href="#main-content"
|
|
@@ -1506,6 +1608,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1506
1608
|
</header>
|
|
1507
1609
|
<ProgressTopBar t={t} className="sticky top-0 z-sticky" completedAutoHideMs={progressCompletedAutoHideMs} />
|
|
1508
1610
|
<main id="main-content" tabIndex={-1} className="flex-1 p-4 lg:p-6 mx-auto w-full max-w-screen-2xl">
|
|
1611
|
+
<InjectionSpot spotId={BACKEND_NAV_BADGES_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1509
1612
|
<InjectionSpot spotId={BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1510
1613
|
<FlashMessages />
|
|
1511
1614
|
<PartialIndexBanner />
|
|
@@ -1620,6 +1723,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1620
1723
|
)}
|
|
1621
1724
|
</div>
|
|
1622
1725
|
<UmesDevToolsPanel />
|
|
1726
|
+
</SidebarCollapseContext.Provider>
|
|
1623
1727
|
</HeaderContext.Provider>
|
|
1624
1728
|
)
|
|
1625
1729
|
}
|