astralyx-ui 0.4.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/registry/index.json +45 -4
- package/registry/items/cookie-consent.json +2 -1
- package/registry/items/dropzone.json +1 -1
- package/registry/items/emoji-picker.json +2 -1
- package/registry/items/event-stream.json +2 -1
- package/registry/items/gantt.json +2 -2
- package/registry/items/input-file.json +1 -1
- package/registry/items/lib-sql-select.json +14 -0
- package/registry/items/query-constructor.json +32 -0
- package/registry/items/scheduler.json +2 -2
- package/registry/items/segment-builder.json +4 -2
- package/registry/items/transfer.json +3 -1
- package/registry/items/tree-select.json +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astralyx-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "309 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,10 +51,11 @@
|
|
|
51
51
|
"sideEffects": false,
|
|
52
52
|
"scripts": {
|
|
53
53
|
"dev": "vite",
|
|
54
|
-
"build": "node scripts/build-props.mjs && tsc -b && vite build && node scripts/build-seo.mjs",
|
|
54
|
+
"build": "node scripts/build-props.mjs && node scripts/check-api.mjs && tsc -b && vite build && node scripts/build-seo.mjs",
|
|
55
55
|
"lint": "oxlint",
|
|
56
56
|
"preview": "vite preview",
|
|
57
57
|
"build:props": "node scripts/build-props.mjs",
|
|
58
|
+
"check:api": "node scripts/check-api.mjs",
|
|
58
59
|
"build:registry": "node scripts/build-registry.mjs",
|
|
59
60
|
"build:cli": "tsc -p tsconfig.cli.json",
|
|
60
61
|
"build:package": "npm run build:props && npm run build:registry && npm run build:cli",
|
package/registry/index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astralyx-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"homepage": "https://ui.astralyx.dev",
|
|
5
5
|
"items": [
|
|
6
6
|
{
|
|
@@ -1074,6 +1074,7 @@
|
|
|
1074
1074
|
"dependencies": [],
|
|
1075
1075
|
"registryDependencies": [
|
|
1076
1076
|
"button",
|
|
1077
|
+
"checkbox",
|
|
1077
1078
|
"lib-styles",
|
|
1078
1079
|
"lib-utils"
|
|
1079
1080
|
]
|
|
@@ -1460,6 +1461,7 @@
|
|
|
1460
1461
|
"lucide-react@^1.39.0"
|
|
1461
1462
|
],
|
|
1462
1463
|
"registryDependencies": [
|
|
1464
|
+
"input",
|
|
1463
1465
|
"lib-styles",
|
|
1464
1466
|
"lib-utils"
|
|
1465
1467
|
]
|
|
@@ -1581,6 +1583,7 @@
|
|
|
1581
1583
|
],
|
|
1582
1584
|
"registryDependencies": [
|
|
1583
1585
|
"button",
|
|
1586
|
+
"input",
|
|
1584
1587
|
"lib-styles",
|
|
1585
1588
|
"lib-utils"
|
|
1586
1589
|
]
|
|
@@ -1763,7 +1766,7 @@
|
|
|
1763
1766
|
"name": "gantt",
|
|
1764
1767
|
"type": "registry:ui",
|
|
1765
1768
|
"title": "Gantt",
|
|
1766
|
-
"description": "Work on a timeline:
|
|
1769
|
+
"description": "Work on a timeline: drag a bar to reschedule and its edges to change the start or end, with a computed critical path and dependency conflicts surfaced as you create them.",
|
|
1767
1770
|
"category": "Views",
|
|
1768
1771
|
"dependencies": [],
|
|
1769
1772
|
"registryDependencies": [
|
|
@@ -3280,6 +3283,31 @@
|
|
|
3280
3283
|
"lib-utils"
|
|
3281
3284
|
]
|
|
3282
3285
|
},
|
|
3286
|
+
{
|
|
3287
|
+
"name": "query-constructor",
|
|
3288
|
+
"type": "registry:ui",
|
|
3289
|
+
"title": "Query Constructor",
|
|
3290
|
+
"description": "A SELECT assembled a clause at a time — sources with a join button, columns, filter, then order, group and limit — with the finished statement underneath. Reads both ways: paste a SELECT and the blocks fill in. Values compile to bound parameters and identifiers are allow-listed against the schema.",
|
|
3291
|
+
"category": "Database",
|
|
3292
|
+
"dependencies": [
|
|
3293
|
+
"lucide-react@^1.39.0"
|
|
3294
|
+
],
|
|
3295
|
+
"registryDependencies": [
|
|
3296
|
+
"button",
|
|
3297
|
+
"code-block",
|
|
3298
|
+
"command",
|
|
3299
|
+
"input",
|
|
3300
|
+
"lib-sql-select",
|
|
3301
|
+
"lib-styles",
|
|
3302
|
+
"lib-utils",
|
|
3303
|
+
"multi-select",
|
|
3304
|
+
"popover",
|
|
3305
|
+
"schema-table",
|
|
3306
|
+
"segment-builder",
|
|
3307
|
+
"select",
|
|
3308
|
+
"textarea"
|
|
3309
|
+
]
|
|
3310
|
+
},
|
|
3283
3311
|
{
|
|
3284
3312
|
"name": "query-editor",
|
|
3285
3313
|
"type": "registry:ui",
|
|
@@ -3743,7 +3771,7 @@
|
|
|
3743
3771
|
"name": "scheduler",
|
|
3744
3772
|
"type": "registry:ui",
|
|
3745
3773
|
"title": "Scheduler",
|
|
3746
|
-
"description": "A week of days with events laid out against the hours
|
|
3774
|
+
"description": "A week of days with events laid out against the hours and edited in place — drag to move across days, drag an edge to change the end, drag empty grid to create. Overlaps split into columns rather than stacking.",
|
|
3747
3775
|
"category": "Views",
|
|
3748
3776
|
"dependencies": [],
|
|
3749
3777
|
"registryDependencies": [
|
|
@@ -3838,9 +3866,11 @@
|
|
|
3838
3866
|
],
|
|
3839
3867
|
"registryDependencies": [
|
|
3840
3868
|
"button",
|
|
3869
|
+
"input",
|
|
3841
3870
|
"lib-styles",
|
|
3842
3871
|
"lib-utils",
|
|
3843
|
-
"select"
|
|
3872
|
+
"select",
|
|
3873
|
+
"tag-input"
|
|
3844
3874
|
]
|
|
3845
3875
|
},
|
|
3846
3876
|
{
|
|
@@ -4808,6 +4838,8 @@
|
|
|
4808
4838
|
],
|
|
4809
4839
|
"registryDependencies": [
|
|
4810
4840
|
"button",
|
|
4841
|
+
"checkbox",
|
|
4842
|
+
"input",
|
|
4811
4843
|
"lib-styles",
|
|
4812
4844
|
"lib-utils"
|
|
4813
4845
|
]
|
|
@@ -4822,6 +4854,8 @@
|
|
|
4822
4854
|
"lucide-react@^1.39.0"
|
|
4823
4855
|
],
|
|
4824
4856
|
"registryDependencies": [
|
|
4857
|
+
"checkbox",
|
|
4858
|
+
"input",
|
|
4825
4859
|
"lib-styles",
|
|
4826
4860
|
"lib-utils",
|
|
4827
4861
|
"primitive-dismissable",
|
|
@@ -5201,6 +5235,13 @@
|
|
|
5201
5235
|
"dependencies": [],
|
|
5202
5236
|
"registryDependencies": []
|
|
5203
5237
|
},
|
|
5238
|
+
{
|
|
5239
|
+
"name": "lib-sql-select",
|
|
5240
|
+
"type": "registry:lib",
|
|
5241
|
+
"title": "sql-select",
|
|
5242
|
+
"dependencies": [],
|
|
5243
|
+
"registryDependencies": []
|
|
5244
|
+
},
|
|
5204
5245
|
{
|
|
5205
5246
|
"name": "lib-styles",
|
|
5206
5247
|
"type": "registry:lib",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"dependencies": [],
|
|
8
8
|
"registryDependencies": [
|
|
9
9
|
"button",
|
|
10
|
+
"checkbox",
|
|
10
11
|
"lib-styles",
|
|
11
12
|
"lib-utils"
|
|
12
13
|
],
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
{
|
|
15
16
|
"path": "components/ui/cookie-consent.tsx",
|
|
16
17
|
"type": "registry:ui",
|
|
17
|
-
"content": "import { useId, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A consent banner with the categories laid out and reject as easy as accept.\n *\n * **Reject is a button of equal weight, and that is not a style choice.** Under\n * the GDPR consent must be freely given, and both the EDPB's dark-pattern\n * guidelines and a series of national DPA rulings have found that hiding\n * refusal behind an extra click — or greying it out beside a bright \"Accept\n * all\" — makes the consent invalid. A banner with only \"Accept\" and \"Manage\"\n * is the single most-fined pattern on the web. This component is built so the\n * compliant arrangement is the default one.\n *\n * **Nothing is on by default except what is strictly necessary.** Pre-ticked\n * boxes are explicitly not consent, so `necessary` is locked on and everything\n * else starts off. Do not \"fix\" this by defaulting analytics to true.\n *\n * **It does not write a cookie, and it does not load anything.** Storage and\n * script loading are the caller's job through `onSave` — because where consent\n * is recorded (cookie, backend, consent-management platform) and what it gates\n * are product decisions, and a component that quietly sets its own cookie is\n * doing the exact thing it is meant to be asking about.\n *\n * It is `role=\"dialog\"` with `aria-modal=\"false\"`: announced and reachable, but\n * it does not trap focus, because a banner people cannot escape is its own\n * accessibility failure.\n */\nexport type ConsentCategory = {\n id: string\n label: ReactNode\n description?: ReactNode\n /** Locked on, and not part of consent. */\n required?: boolean\n defaultEnabled?: boolean\n}\n\nexport const DEFAULT_CATEGORIES: ConsentCategory[] = [\n {\n id: 'necessary',\n label: 'Strictly necessary',\n description: 'Sign-in, security and load balancing. The site does not work without these.',\n required: true,\n },\n {\n id: 'analytics',\n label: 'Analytics',\n description: 'How the site is used, in aggregate, so it can be improved.',\n },\n {\n id: 'marketing',\n label: 'Marketing',\n description: 'Measuring campaigns and showing relevant ads on other sites.',\n },\n]\n\ntype CookieConsentProps = Omit<ComponentProps<'div'>, 'onChange' | 'title'> & {\n title?: ReactNode\n description?: ReactNode\n categories?: ConsentCategory[]\n /** Called with the ids that were consented to. */\n onSave?: (accepted: string[]) => void\n onAcceptAll?: (accepted: string[]) => void\n onRejectAll?: (accepted: string[]) => void\n acceptAllLabel?: string\n rejectAllLabel?: string\n customiseLabel?: string\n saveLabel?: string\n policyHref?: string\n policyLabel?: string\n /** Start with the categories expanded. */\n defaultOpen?: boolean\n /** Fixed to the bottom of the viewport, as it would be in production. */\n fixed?: boolean\n}\n\nfunction CookieConsent({\n title = 'Cookies',\n description = 'We use cookies to run the site, and — with your permission — to understand how it is used.',\n categories = DEFAULT_CATEGORIES,\n onSave,\n onAcceptAll,\n onRejectAll,\n acceptAllLabel = 'Accept all',\n rejectAllLabel = 'Reject all',\n customiseLabel = 'Customise',\n saveLabel = 'Save choices',\n policyHref,\n policyLabel = 'Privacy policy',\n defaultOpen = false,\n fixed = false,\n className,\n ...props\n}: CookieConsentProps) {\n const scope = useId()\n const [open, setOpen] = useState(defaultOpen)\n const [enabled, setEnabled] = useState<Record<string, boolean>>(() =>\n Object.fromEntries(\n // Nothing optional starts on. Pre-ticked is not consent.\n categories.map((category) => [\n category.id,\n Boolean(category.required || category.defaultEnabled),\n ]),\n ),\n )\n\n const required = categories.filter((category) => category.required).map((category) => category.id)\n const allIds = categories.map((category) => category.id)\n\n return (\n <div\n data-slot=\"cookie-consent\"\n role=\"dialog\"\n // Deliberately not modal: trapping focus in a consent banner is its own\n // accessibility failure.\n aria-modal=\"false\"\n aria-labelledby={`${scope}-title`}\n aria-describedby={`${scope}-description`}\n className={cn(\n surface,\n radius.surface,\n 'p-4 shadow-lg',\n fixed && 'fixed inset-x-4 bottom-4 z-50 mx-auto max-w-3xl',\n className,\n )}\n {...props}\n >\n <p id={`${scope}-title`} className=\"text-sm font-medium\">\n {title}\n </p>\n <p id={`${scope}-description`} className=\"text-muted-foreground mt-1 text-xs\">\n {description}\n {policyHref && (\n <>\n {' '}\n <a\n href={policyHref}\n className={cn('underline underline-offset-2', focusRing)}\n rel=\"noreferrer\"\n >\n {policyLabel}\n </a>\n </>\n )}\n </p>\n\n {open && (\n <ul className=\"mt-3 list-none space-y-2\">\n {categories.map((category) => (\n <li\n key={category.id}\n className={cn('border-border flex items-start gap-3 border p-3', radius.control)}\n >\n <
|
|
18
|
+
"content": "import { useId, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A consent banner with the categories laid out and reject as easy as accept.\n *\n * **Reject is a button of equal weight, and that is not a style choice.** Under\n * the GDPR consent must be freely given, and both the EDPB's dark-pattern\n * guidelines and a series of national DPA rulings have found that hiding\n * refusal behind an extra click — or greying it out beside a bright \"Accept\n * all\" — makes the consent invalid. A banner with only \"Accept\" and \"Manage\"\n * is the single most-fined pattern on the web. This component is built so the\n * compliant arrangement is the default one.\n *\n * **Nothing is on by default except what is strictly necessary.** Pre-ticked\n * boxes are explicitly not consent, so `necessary` is locked on and everything\n * else starts off. Do not \"fix\" this by defaulting analytics to true.\n *\n * **It does not write a cookie, and it does not load anything.** Storage and\n * script loading are the caller's job through `onSave` — because where consent\n * is recorded (cookie, backend, consent-management platform) and what it gates\n * are product decisions, and a component that quietly sets its own cookie is\n * doing the exact thing it is meant to be asking about.\n *\n * It is `role=\"dialog\"` with `aria-modal=\"false\"`: announced and reachable, but\n * it does not trap focus, because a banner people cannot escape is its own\n * accessibility failure.\n */\nexport type ConsentCategory = {\n id: string\n label: ReactNode\n description?: ReactNode\n /** Locked on, and not part of consent. */\n required?: boolean\n defaultEnabled?: boolean\n}\n\nexport const DEFAULT_CATEGORIES: ConsentCategory[] = [\n {\n id: 'necessary',\n label: 'Strictly necessary',\n description: 'Sign-in, security and load balancing. The site does not work without these.',\n required: true,\n },\n {\n id: 'analytics',\n label: 'Analytics',\n description: 'How the site is used, in aggregate, so it can be improved.',\n },\n {\n id: 'marketing',\n label: 'Marketing',\n description: 'Measuring campaigns and showing relevant ads on other sites.',\n },\n]\n\ntype CookieConsentProps = Omit<ComponentProps<'div'>, 'onChange' | 'title'> & {\n title?: ReactNode\n description?: ReactNode\n categories?: ConsentCategory[]\n /** Called with the ids that were consented to. */\n onSave?: (accepted: string[]) => void\n onAcceptAll?: (accepted: string[]) => void\n onRejectAll?: (accepted: string[]) => void\n acceptAllLabel?: string\n rejectAllLabel?: string\n customiseLabel?: string\n saveLabel?: string\n policyHref?: string\n policyLabel?: string\n /** Start with the categories expanded. */\n defaultOpen?: boolean\n /** Fixed to the bottom of the viewport, as it would be in production. */\n fixed?: boolean\n}\n\nfunction CookieConsent({\n title = 'Cookies',\n description = 'We use cookies to run the site, and — with your permission — to understand how it is used.',\n categories = DEFAULT_CATEGORIES,\n onSave,\n onAcceptAll,\n onRejectAll,\n acceptAllLabel = 'Accept all',\n rejectAllLabel = 'Reject all',\n customiseLabel = 'Customise',\n saveLabel = 'Save choices',\n policyHref,\n policyLabel = 'Privacy policy',\n defaultOpen = false,\n fixed = false,\n className,\n ...props\n}: CookieConsentProps) {\n const scope = useId()\n const [open, setOpen] = useState(defaultOpen)\n const [enabled, setEnabled] = useState<Record<string, boolean>>(() =>\n Object.fromEntries(\n // Nothing optional starts on. Pre-ticked is not consent.\n categories.map((category) => [\n category.id,\n Boolean(category.required || category.defaultEnabled),\n ]),\n ),\n )\n\n const required = categories.filter((category) => category.required).map((category) => category.id)\n const allIds = categories.map((category) => category.id)\n\n return (\n <div\n data-slot=\"cookie-consent\"\n role=\"dialog\"\n // Deliberately not modal: trapping focus in a consent banner is its own\n // accessibility failure.\n aria-modal=\"false\"\n aria-labelledby={`${scope}-title`}\n aria-describedby={`${scope}-description`}\n className={cn(\n surface,\n radius.surface,\n 'p-4 shadow-lg',\n fixed && 'fixed inset-x-4 bottom-4 z-50 mx-auto max-w-3xl',\n className,\n )}\n {...props}\n >\n <p id={`${scope}-title`} className=\"text-sm font-medium\">\n {title}\n </p>\n <p id={`${scope}-description`} className=\"text-muted-foreground mt-1 text-xs\">\n {description}\n {policyHref && (\n <>\n {' '}\n <a\n href={policyHref}\n className={cn('underline underline-offset-2', focusRing)}\n rel=\"noreferrer\"\n >\n {policyLabel}\n </a>\n </>\n )}\n </p>\n\n {open && (\n <ul className=\"mt-3 list-none space-y-2\">\n {categories.map((category) => (\n <li\n key={category.id}\n className={cn('border-border flex items-start gap-3 border p-3', radius.control)}\n >\n <Checkbox\n id={`${scope}-${category.id}`}\n checked={enabled[category.id] ?? false}\n disabled={category.required}\n onChange={(event) =>\n setEnabled((current) => ({ ...current, [category.id]: event.target.checked }))\n }\n containerClassName=\"mt-0.5 shrink-0\"\n />\n <label htmlFor={`${scope}-${category.id}`} className=\"min-w-0 flex-1 cursor-pointer\">\n <span className=\"flex items-center gap-2 text-sm font-medium\">\n {category.label}\n {category.required && (\n <span className=\"text-muted-foreground text-[11px] font-normal\">Always on</span>\n )}\n </span>\n {category.description && (\n <span className=\"text-muted-foreground mt-0.5 block text-xs\">\n {category.description}\n </span>\n )}\n </label>\n </li>\n ))}\n </ul>\n )}\n\n <div className=\"mt-4 flex flex-wrap items-center gap-2\">\n {/* Reject sits beside accept, same size, same prominence. Anything else\n is a dark pattern regulators have already ruled on. */}\n <Button\n size=\"sm\"\n onClick={() => (onAcceptAll ? onAcceptAll(allIds) : onSave?.(allIds))}\n >\n {acceptAllLabel}\n </Button>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n onClick={() => (onRejectAll ? onRejectAll(required) : onSave?.(required))}\n >\n {rejectAllLabel}\n </Button>\n\n {open ? (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() =>\n onSave?.(allIds.filter((id) => enabled[id] || required.includes(id)))\n }\n >\n {saveLabel}\n </Button>\n ) : (\n <Button size=\"sm\" variant=\"ghost\" onClick={() => setOpen(true)}>\n {customiseLabel}\n </Button>\n )}\n </div>\n </div>\n )\n}\n\nexport { CookieConsent }\nexport type { CookieConsentProps }\n"
|
|
18
19
|
}
|
|
19
20
|
]
|
|
20
21
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
{
|
|
20
20
|
"path": "components/ui/dropzone.tsx",
|
|
21
21
|
"type": "registry:ui",
|
|
22
|
-
"content": "import {\n useRef,\n useState,\n type ComponentProps,\n type DragEvent,\n type ReactNode,\n} from 'react'\nimport { CheckCircle2, CloudUpload } from 'lucide-react'\nimport { Spinner } from '@/components/ui/spinner'\nimport { UploadList } from '@/components/ui/upload-list'\nimport type { Attachment } from '@/components/ui/attachment-preview'\nimport {\n formatBytes,\n useUploads,\n type FileUpload,\n type UploadHandler,\n} from '@/lib/use-uploads'\nimport { disabledState, focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The upload card: click it or drag onto it, and it runs the upload.\n *\n * Drag and drop is a pointer-only affordance, so the card is a real `<button>`\n * wrapping a visually hidden `<input type=\"file\">` — keyboard and screen-reader\n * users get the file picker, everyone else can drag onto it. A `div` with a\n * drop handler and no keyboard path is the usual version of this component and\n * it is unusable without a mouse.\n *\n * `dragDepth` counts enter/leave rather than tracking a boolean: dragging over\n * a child fires `dragleave` on the parent, so a boolean flickers the highlight\n * off every time the pointer crosses the icon inside the card.\n *\n * The upload itself lives in `useUploads`, shared with `InputFile`. Hand this\n * component `onUpload` and it moves each file through queued → uploading → done\n * or error, reports progress, and keeps failures on screen with a retry. While\n * anything is in flight the card swaps to a spinner naming the file, so the\n * component that took the drop is also the one that says what happened to it.\n *\n * Validation runs on drop as well as on pick. The browser filters the file\n * picker by `accept`, but a dropped file never went through the picker — so\n * without a check here, dragging a `.mov` onto an image-only zone uploads it.\n */\ntype DropzoneProps = Omit<ComponentProps<'div'>, 'onChange' | 'onSelect'> & {\n /**\n * Runs the upload. Resolve to succeed — the value lands on `upload.result`;\n * throw to fail, and the message shows on the row with a retry.\n */\n onUpload?: UploadHandler\n /** Fires on selection, before any upload starts. */\n onSelect?: (uploads: FileUpload[]) => void\n /** Fires on every transition — start, progress, finish, failure, removal. */\n onUploadsChange?: (uploads: FileUpload[]) => void\n accept?: string\n multiple?: boolean\n disabled?: boolean\n /** Rejected before the request is made, in bytes. */\n maxSize?: number\n label?: ReactNode\n hint?: ReactNode\n /** Show the per-file rows under the card. */\n showList?: boolean\n /**\n * Force the busy state from outside.\n *\n * For the case where the request is yours — you are not using `onUpload`, you\n * are posting the files yourself and want the card to say so. `true` shows\n * the spinner and blocks further picking. Left undefined, the component uses\n * its own upload state.\n */\n isUploading?: boolean\n /** Names the file being sent. Receives the file name, or a count. */\n uploadingLabel?: (name: string) => ReactNode\n /** Shown once everything has landed. Receives how many. */\n doneLabel?: (count: number) => ReactNode\n maxSizeLabel?: (limit: string) => string\n acceptLabel?: (accept: string) => string\n}\n\nfunction Dropzone({\n onUpload,\n onSelect,\n onUploadsChange,\n accept,\n multiple = false,\n disabled = false,\n maxSize,\n label = 'Click to upload, or drag and drop',\n hint,\n showList = true,\n isUploading,\n uploadingLabel = (name) => `Uploading ${name}…`,\n doneLabel = (count) => `${count} file${count === 1 ? '' : 's'} uploaded`,\n maxSizeLabel,\n acceptLabel,\n className,\n ...props\n}: DropzoneProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const [dragDepth, setDragDepth] = useState(0)\n\n const { uploads, select, remove, retry, uploading, failed, done } = useUploads({\n onUpload,\n multiple,\n maxSize,\n maxSizeLabel,\n accept,\n acceptLabel,\n onSelect,\n onUploadsChange,\n })\n\n // The prop wins when given, so a caller running its own request can drive the\n // card without adopting `onUpload`.\n const busy = isUploading ?? uploading\n const over = dragDepth > 0 && !disabled && !busy\n\n const inFlight = uploads.find((upload) => upload.status === 'uploading')\n const settled = done > 0 && !busy && !failed\n\n function onDrop(event: DragEvent<HTMLElement>) {\n event.preventDefault()\n setDragDepth(0)\n if (disabled || busy) return\n select([...event.dataTransfer.files])\n }\n\n /** `UploadList` is presentational and takes `Attachment`, so map into it. */\n const attachments: Attachment[] = uploads.map((upload) => ({\n id: upload.id,\n name: upload.name,\n type: upload.type,\n size: upload.size,\n progress: upload.status === 'done' ? undefined : upload.progress,\n error: upload.error,\n }))\n\n return (\n <div data-slot=\"dropzone\" className={cn('flex w-full flex-col gap-2', className)} {...props}>\n <button\n type=\"button\"\n disabled={disabled || busy}\n data-dragging={over}\n data-busy={busy}\n onClick={() => inputRef.current?.click()}\n onDragEnter={(event) => {\n event.preventDefault()\n setDragDepth((depth) => depth + 1)\n }}\n onDragLeave={() => setDragDepth((depth) => Math.max(0, depth - 1))}\n onDragOver={(event) => event.preventDefault()}\n onDrop={onDrop}\n className={cn(\n 'flex w-full flex-col items-center justify-center gap-2 border border-dashed px-6 py-9 text-center',\n radius.surface,\n interactive,\n focusRing,\n disabledState,\n // Busy is not disabled-looking: the card is still the thing telling\n // you what is happening, so it keeps full contrast.\n busy && 'opacity-100',\n over\n ? 'border-primary bg-accent text-foreground'\n : failed\n ? 'border-destructive text-muted-foreground'\n : 'border-border text-muted-foreground hover:bg-accent/40 hover:text-foreground',\n )}\n >\n {busy ? (\n <>\n <Spinner size=\"sm\" label=\"Uploading\" />\n <span className=\"text-foreground max-w-full truncate text-sm font-medium\">\n {uploadingLabel(\n inFlight\n ? inFlight.name\n : `${uploads.length} file${uploads.length === 1 ? '' : 's'}`,\n )}\n </span>\n </>\n ) : settled ? (\n <>\n <CheckCircle2\n className=\"size-5 shrink-0 text-[var(--green-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n <span className=\"text-foreground text-sm font-medium\">{doneLabel(done)}</span>\n <span className=\"text-muted-foreground text-xs\">{label}</span>\n </>\n ) : (\n <>\n <CloudUpload className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-foreground text-sm font-medium\">{label}</span>\n {hint ? (\n <span className=\"text-muted-foreground text-xs\">{hint}</span>\n ) : (\n (accept || maxSize !== undefined) && (\n <span className=\"text-muted-foreground text-xs\">\n {[accept, maxSize !== undefined ? `up to ${formatBytes(maxSize)}` : null]\n .filter(Boolean)\n .join(' · ')}\n </span>\n )\n )}\n </>\n )}\n </button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled || busy}\n // The button above is the control; this stays out of the tab order so\n // there is one stop, not two.\n tabIndex={-1}\n aria-hidden=\"true\"\n className=\"sr-only\"\n onChange={(event) => {\n select([...(event.target.files ?? [])])\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n />\n\n {showList && uploads.length > 0 && (\n <UploadList uploads={attachments} onRemove={remove} onRetry={retry} />\n )}\n </div>\n )\n}\n\nexport { Dropzone }\nexport type { DropzoneProps }\n"
|
|
22
|
+
"content": "import {\n useRef,\n useState,\n type ComponentProps,\n type DragEvent,\n type ReactNode,\n} from 'react'\nimport { CheckCircle2, CloudUpload } from 'lucide-react'\nimport { Spinner } from '@/components/ui/spinner'\nimport { UploadList } from '@/components/ui/upload-list'\nimport type { Attachment } from '@/components/ui/attachment-preview'\nimport {\n formatBytes,\n useUploads,\n type FileUpload,\n type UploadHandler,\n} from '@/lib/use-uploads'\nimport { disabledState, focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The upload card: click it or drag onto it, and it runs the upload.\n *\n * Drag and drop is a pointer-only affordance, so the card is a real `<button>`\n * wrapping a visually hidden `<input type=\"file\">` — keyboard and screen-reader\n * users get the file picker, everyone else can drag onto it. A `div` with a\n * drop handler and no keyboard path is the usual version of this component and\n * it is unusable without a mouse.\n *\n * `dragDepth` counts enter/leave rather than tracking a boolean: dragging over\n * a child fires `dragleave` on the parent, so a boolean flickers the highlight\n * off every time the pointer crosses the icon inside the card.\n *\n * The upload itself lives in `useUploads`, shared with `InputFile`. Hand this\n * component `onUpload` and it moves each file through queued → uploading → done\n * or error, reports progress, and keeps failures on screen with a retry. While\n * anything is in flight the card swaps to a spinner naming the file, so the\n * component that took the drop is also the one that says what happened to it.\n *\n * Validation runs on drop as well as on pick. The browser filters the file\n * picker by `accept`, but a dropped file never went through the picker — so\n * without a check here, dragging a `.mov` onto an image-only zone uploads it.\n */\ntype DropzoneProps = Omit<ComponentProps<'div'>, 'onChange' | 'onSelect'> & {\n /**\n * Runs the upload. Resolve to succeed — the value lands on `upload.result`;\n * throw to fail, and the message shows on the row with a retry.\n */\n onUpload?: UploadHandler\n /** Fires on selection, before any upload starts. */\n onSelect?: (uploads: FileUpload[]) => void\n /** Fires on every transition — start, progress, finish, failure, removal. */\n onUploadsChange?: (uploads: FileUpload[]) => void\n accept?: string\n multiple?: boolean\n disabled?: boolean\n /** Rejected before the request is made, in bytes. */\n maxSize?: number\n label?: ReactNode\n hint?: ReactNode\n /** Show the per-file rows under the card. */\n showList?: boolean\n /**\n * Force the busy state from outside.\n *\n * For the case where the request is yours — you are not using `onUpload`, you\n * are posting the files yourself and want the card to say so. `true` shows\n * the spinner and blocks further picking. Left undefined, the component uses\n * its own upload state.\n */\n isUploading?: boolean\n /** Names the file being sent. Receives the file name, or a count. */\n uploadingLabel?: (name: string) => ReactNode\n /** Shown once everything has landed. Receives how many. */\n doneLabel?: (count: number) => ReactNode\n maxSizeLabel?: (limit: string) => string\n acceptLabel?: (accept: string) => string\n}\n\nfunction Dropzone({\n onUpload,\n onSelect,\n onUploadsChange,\n accept,\n multiple = false,\n disabled = false,\n maxSize,\n label = 'Click to upload, or drag and drop',\n hint,\n showList = true,\n isUploading,\n uploadingLabel = (name) => `Uploading ${name}…`,\n doneLabel = (count) => `${count} file${count === 1 ? '' : 's'} uploaded`,\n maxSizeLabel,\n acceptLabel,\n className,\n ...props\n}: DropzoneProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const [dragDepth, setDragDepth] = useState(0)\n\n const { uploads, select, remove, retry, uploading, failed, done } = useUploads({\n onUpload,\n multiple,\n maxSize,\n maxSizeLabel,\n accept,\n acceptLabel,\n onSelect,\n onUploadsChange,\n })\n\n // The prop wins when given, so a caller running its own request can drive the\n // card without adopting `onUpload`.\n const busy = isUploading ?? uploading\n const over = dragDepth > 0 && !disabled && !busy\n\n const inFlight = uploads.find((upload) => upload.status === 'uploading')\n const settled = done > 0 && !busy && !failed\n\n function onDrop(event: DragEvent<HTMLElement>) {\n event.preventDefault()\n setDragDepth(0)\n if (disabled || busy) return\n select([...event.dataTransfer.files])\n }\n\n /** `UploadList` is presentational and takes `Attachment`, so map into it. */\n const attachments: Attachment[] = uploads.map((upload) => ({\n id: upload.id,\n name: upload.name,\n type: upload.type,\n size: upload.size,\n progress: upload.status === 'done' ? undefined : upload.progress,\n error: upload.error,\n }))\n\n return (\n <div data-slot=\"dropzone\" className={cn('flex w-full flex-col gap-2', className)} {...props}>\n <button\n type=\"button\"\n disabled={disabled || busy}\n data-dragging={over}\n data-busy={busy}\n onClick={() => inputRef.current?.click()}\n onDragEnter={(event) => {\n event.preventDefault()\n setDragDepth((depth) => depth + 1)\n }}\n onDragLeave={() => setDragDepth((depth) => Math.max(0, depth - 1))}\n onDragOver={(event) => event.preventDefault()}\n onDrop={onDrop}\n className={cn(\n 'flex w-full flex-col items-center justify-center gap-2 border border-dashed px-6 py-9 text-center',\n radius.surface,\n interactive,\n focusRing,\n disabledState,\n // Busy is not disabled-looking: the card is still the thing telling\n // you what is happening, so it keeps full contrast.\n busy && 'opacity-100',\n over\n ? 'border-primary bg-accent text-foreground'\n : failed\n ? 'border-destructive text-muted-foreground'\n : 'border-border text-muted-foreground hover:bg-accent/40 hover:text-foreground',\n )}\n >\n {busy ? (\n <>\n <Spinner size=\"sm\" label=\"Uploading\" />\n <span className=\"text-foreground max-w-full truncate text-sm font-medium\">\n {uploadingLabel(\n inFlight\n ? inFlight.name\n : `${uploads.length} file${uploads.length === 1 ? '' : 's'}`,\n )}\n </span>\n </>\n ) : settled ? (\n <>\n <CheckCircle2\n className=\"size-5 shrink-0 text-[var(--green-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n <span className=\"text-foreground text-sm font-medium\">{doneLabel(done)}</span>\n <span className=\"text-muted-foreground text-xs\">{label}</span>\n </>\n ) : (\n <>\n <CloudUpload className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-foreground text-sm font-medium\">{label}</span>\n {hint ? (\n <span className=\"text-muted-foreground text-xs\">{hint}</span>\n ) : (\n (accept || maxSize !== undefined) && (\n <span className=\"text-muted-foreground text-xs\">\n {[accept, maxSize !== undefined ? `up to ${formatBytes(maxSize)}` : null]\n .filter(Boolean)\n .join(' · ')}\n </span>\n )\n )}\n </>\n )}\n </button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled || busy}\n // The button above is the control; this stays out of the tab order so\n // there is one stop, not two.\n tabIndex={-1}\n aria-hidden=\"true\"\n className=\"sr-only\"\n onChange={(event) => {\n select([...(event.target.files ?? [])])\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n />\n\n {showList && uploads.length > 0 && (\n <UploadList uploads={attachments} onRemove={remove} onRetry={retry} />\n )}\n </div>\n )\n}\n\nexport { Dropzone }\nexport type { DropzoneProps }\nexport type { FileUpload, UploadControl, UploadHandler } from '@/lib/use-uploads'\n"
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
25
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"lucide-react@^1.39.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
+
"input",
|
|
11
12
|
"lib-styles",
|
|
12
13
|
"lib-utils"
|
|
13
14
|
],
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
{
|
|
16
17
|
"path": "components/ui/emoji-picker.tsx",
|
|
17
18
|
"type": "registry:ui",
|
|
18
|
-
"content": "import { useMemo, useRef, useState, type ComponentProps } from 'react'\nimport { Search } from 'lucide-react'\nimport { fieldBase, fieldOutline, focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A grid of emoji, grouped, searchable, with recents.\n *\n * **The list is a prop with a small curated default, not all 3,700.** A full\n * emoji set with keywords is roughly 200 kB of JSON before any images, and it\n * has to be updated with every Unicode release. Most products need the\n * reactions people actually use; if you need the whole set, pass it — the\n * component does not care how long the array is.\n *\n * **They are rendered as text, not images.** System emoji fonts are already\n * installed, already the right style for the platform, scale as text, and cost\n * nothing to load. Sprite sheets exist to make emoji look identical everywhere,\n * which is a design goal worth having only if you are also willing to ship and\n * maintain the sheet.\n *\n * **Recents live with the caller.** This component never touches\n * `localStorage`: a picker that writes to storage on its own cannot be\n * server-rendered predictably, cannot be reset by the app, and puts a key in\n * someone's browser they did not choose. `recent` and `onSelect` are enough for\n * the caller to persist it wherever they already persist preferences.\n *\n * The grid is one tab stop with arrow-key movement — a roving tabindex —\n * because eight tab stops per row is unusable with a keyboard.\n */\nexport type Emoji = {\n char: string\n name: string\n group: string\n /** Extra search terms beyond the name. */\n keywords?: string[]\n}\n\n/** A small, opinionated default: the ones that get used. */\nexport const COMMON_EMOJI: Emoji[] = [\n { char: '👍', name: 'thumbs up', group: 'Reactions', keywords: ['yes', 'ok', 'approve'] },\n { char: '👎', name: 'thumbs down', group: 'Reactions', keywords: ['no', 'reject'] },\n { char: '❤️', name: 'heart', group: 'Reactions', keywords: ['love', 'like'] },\n { char: '🎉', name: 'party popper', group: 'Reactions', keywords: ['celebrate', 'ship'] },\n { char: '🚀', name: 'rocket', group: 'Reactions', keywords: ['ship', 'launch', 'deploy'] },\n { char: '👀', name: 'eyes', group: 'Reactions', keywords: ['looking', 'review'] },\n { char: '🔥', name: 'fire', group: 'Reactions', keywords: ['hot', 'good'] },\n { char: '✅', name: 'check mark', group: 'Reactions', keywords: ['done', 'pass'] },\n { char: '❌', name: 'cross mark', group: 'Reactions', keywords: ['no', 'fail'] },\n { char: '⚠️', name: 'warning', group: 'Reactions', keywords: ['careful', 'risk'] },\n\n { char: '😀', name: 'grinning', group: 'Smileys', keywords: ['happy', 'smile'] },\n { char: '😂', name: 'tears of joy', group: 'Smileys', keywords: ['laugh', 'lol'] },\n { char: '🙂', name: 'slight smile', group: 'Smileys' },\n { char: '😅', name: 'sweat smile', group: 'Smileys', keywords: ['phew'] },\n { char: '🤔', name: 'thinking', group: 'Smileys', keywords: ['hmm', 'consider'] },\n { char: '😍', name: 'heart eyes', group: 'Smileys', keywords: ['love'] },\n { char: '😢', name: 'crying', group: 'Smileys', keywords: ['sad'] },\n { char: '😴', name: 'sleeping', group: 'Smileys', keywords: ['tired', 'zzz'] },\n { char: '🤯', name: 'mind blown', group: 'Smileys', keywords: ['wow'] },\n { char: '🫠', name: 'melting face', group: 'Smileys', keywords: ['hot', 'stress'] },\n\n { char: '💻', name: 'laptop', group: 'Work', keywords: ['code', 'dev'] },\n { char: '🐛', name: 'bug', group: 'Work', keywords: ['issue', 'defect'] },\n { char: '📦', name: 'package', group: 'Work', keywords: ['release', 'ship', 'npm'] },\n { char: '🧪', name: 'test tube', group: 'Work', keywords: ['test', 'experiment'] },\n { char: '📈', name: 'chart up', group: 'Work', keywords: ['growth', 'metrics'] },\n { char: '📉', name: 'chart down', group: 'Work', keywords: ['drop', 'regression'] },\n { char: '🔧', name: 'wrench', group: 'Work', keywords: ['fix', 'config'] },\n { char: '🔒', name: 'locked', group: 'Work', keywords: ['security', 'private'] },\n { char: '📝', name: 'memo', group: 'Work', keywords: ['note', 'docs'] },\n { char: '⏰', name: 'alarm clock', group: 'Work', keywords: ['deadline', 'time'] },\n\n { char: '☕', name: 'coffee', group: 'Life', keywords: ['break'] },\n { char: '🍕', name: 'pizza', group: 'Life', keywords: ['food'] },\n { char: '🌧️', name: 'rain', group: 'Life', keywords: ['weather'] },\n { char: '🌞', name: 'sun', group: 'Life', keywords: ['weather', 'sunny'] },\n { char: '🐙', name: 'octopus', group: 'Life', keywords: ['git', 'octocat'] },\n { char: '🐈', name: 'cat', group: 'Life', keywords: ['pet'] },\n { char: '🎧', name: 'headphone', group: 'Life', keywords: ['music', 'focus'] },\n { char: '✈️', name: 'airplane', group: 'Life', keywords: ['travel', 'ooo'] },\n]\n\ntype EmojiPickerProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n emoji?: Emoji[]\n onSelect?: (emoji: Emoji) => void\n /** Characters, most recent first. Persisted by the caller, not by this. */\n recent?: string[]\n recentLabel?: string\n columns?: number\n searchable?: boolean\n searchPlaceholder?: string\n emptyLabel?: string\n /** Height of the scrolling area. */\n height?: number\n label?: string\n}\n\nfunction EmojiPicker({\n emoji = COMMON_EMOJI,\n onSelect,\n recent = [],\n recentLabel = 'Recent',\n columns = 8,\n searchable = true,\n searchPlaceholder = 'Search emoji',\n emptyLabel = 'No emoji matches.',\n height = 240,\n label = 'Emoji picker',\n className,\n ...props\n}: EmojiPickerProps) {\n const [query, setQuery] = useState('')\n const [active, setActive] = useState(0)\n const gridRef = useRef<HTMLDivElement>(null)\n\n const matches = useMemo(() => {\n const needle = query.trim().toLowerCase()\n if (!needle) return emoji\n return emoji.filter(\n (item) =>\n item.name.includes(needle) ||\n item.keywords?.some((word) => word.includes(needle)) ||\n item.char === needle,\n )\n }, [emoji, query])\n\n /** Recents first as their own group, then the natural grouping. */\n const groups = useMemo(() => {\n const out: { name: string; items: Emoji[] }[] = []\n if (!query && recent.length > 0) {\n const found = recent\n .map((char) => emoji.find((item) => item.char === char))\n .filter(Boolean) as Emoji[]\n if (found.length) out.push({ name: recentLabel, items: found })\n }\n for (const item of matches) {\n const bucket = out.find((group) => group.name === item.group && group.name !== recentLabel)\n if (bucket) bucket.items.push(item)\n else out.push({ name: item.group, items: [item] })\n }\n return out\n }, [matches, recent, emoji, query, recentLabel])\n\n /** Flat order, so arrow keys cross group boundaries the way the eye does. */\n const flat = useMemo(() => groups.flatMap((group) => group.items), [groups])\n\n function onKeyDown(event: React.KeyboardEvent) {\n const step =\n event.key === 'ArrowRight' ? 1\n : event.key === 'ArrowLeft' ? -1\n : event.key === 'ArrowDown' ? columns\n : event.key === 'ArrowUp' ? -columns\n : 0\n\n if (step === 0) {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n const picked = flat[active]\n if (picked) onSelect?.(picked)\n }\n return\n }\n\n event.preventDefault()\n const next = Math.min(Math.max(0, active + step), flat.length - 1)\n setActive(next)\n // Follow the focus, or arrowing past the fold looks like nothing happened.\n gridRef.current\n ?.querySelector<HTMLButtonElement>(`[data-index=\"${next}\"]`)\n ?.focus()\n }\n\n let index = -1\n\n return (\n <div\n data-slot=\"emoji-picker\"\n className={cn(surface, radius.surface, 'flex w-72 flex-col overflow-hidden', className)}\n {...props}\n >\n {searchable && (\n <div className=\"border-border border-b p-2\">\n <div className={cn(fieldBase, fieldOutline, 'flex h-8 items-center gap-2 px-2')}>\n <Search aria-hidden=\"true\" className=\"text-muted-foreground size-3.5 shrink-0\" />\n <input\n value={query}\n onChange={(event) => {\n setQuery(event.target.value)\n setActive(0)\n }}\n placeholder={searchPlaceholder}\n aria-label={searchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm outline-none\"\n />\n </div>\n </div>\n )}\n\n <div\n ref={gridRef}\n role=\"grid\"\n aria-label={label}\n style={{ height }}\n onKeyDown={onKeyDown}\n className=\"min-h-0 flex-1 overflow-y-auto p-2\"\n >\n {flat.length === 0 ? (\n <p className=\"text-muted-foreground p-2 text-xs\">{emptyLabel}</p>\n ) : (\n groups.map((group) => (\n <section key={group.name} className=\"mb-2 last:mb-0\">\n <h3 className=\"text-muted-foreground px-1 py-1 text-[11px] font-medium tracking-wide uppercase\">\n {group.name}\n </h3>\n <div\n role=\"row\"\n className=\"grid gap-0.5\"\n style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}\n >\n {group.items.map((item) => {\n index += 1\n const position = index\n return (\n <button\n key={`${group.name}-${item.char}`}\n type=\"button\"\n role=\"gridcell\"\n data-index={position}\n // Roving tabindex: one stop for the whole grid.\n tabIndex={position === active ? 0 : -1}\n aria-label={item.name}\n title={item.name}\n onFocus={() => setActive(position)}\n onClick={() => onSelect?.(item)}\n className={cn(\n 'flex aspect-square items-center justify-center text-lg leading-none',\n radius.xs,\n 'hover:bg-muted',\n focusRing,\n )}\n >\n <span aria-hidden=\"true\">{item.char}</span>\n </button>\n )\n })}\n </div>\n </section>\n ))\n )}\n </div>\n </div>\n )\n}\n\nexport { EmojiPicker }\nexport type { EmojiPickerProps }\n"
|
|
19
|
+
"content": "import { useMemo, useRef, useState, type ComponentProps } from 'react'\nimport { Search } from 'lucide-react'\nimport { Input } from '@/components/ui/input'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A grid of emoji, grouped, searchable, with recents.\n *\n * **The list is a prop with a small curated default, not all 3,700.** A full\n * emoji set with keywords is roughly 200 kB of JSON before any images, and it\n * has to be updated with every Unicode release. Most products need the\n * reactions people actually use; if you need the whole set, pass it — the\n * component does not care how long the array is.\n *\n * **They are rendered as text, not images.** System emoji fonts are already\n * installed, already the right style for the platform, scale as text, and cost\n * nothing to load. Sprite sheets exist to make emoji look identical everywhere,\n * which is a design goal worth having only if you are also willing to ship and\n * maintain the sheet.\n *\n * **Recents live with the caller.** This component never touches\n * `localStorage`: a picker that writes to storage on its own cannot be\n * server-rendered predictably, cannot be reset by the app, and puts a key in\n * someone's browser they did not choose. `recent` and `onSelect` are enough for\n * the caller to persist it wherever they already persist preferences.\n *\n * The grid is one tab stop with arrow-key movement — a roving tabindex —\n * because eight tab stops per row is unusable with a keyboard.\n */\nexport type Emoji = {\n char: string\n name: string\n group: string\n /** Extra search terms beyond the name. */\n keywords?: string[]\n}\n\n/** A small, opinionated default: the ones that get used. */\nexport const COMMON_EMOJI: Emoji[] = [\n { char: '👍', name: 'thumbs up', group: 'Reactions', keywords: ['yes', 'ok', 'approve'] },\n { char: '👎', name: 'thumbs down', group: 'Reactions', keywords: ['no', 'reject'] },\n { char: '❤️', name: 'heart', group: 'Reactions', keywords: ['love', 'like'] },\n { char: '🎉', name: 'party popper', group: 'Reactions', keywords: ['celebrate', 'ship'] },\n { char: '🚀', name: 'rocket', group: 'Reactions', keywords: ['ship', 'launch', 'deploy'] },\n { char: '👀', name: 'eyes', group: 'Reactions', keywords: ['looking', 'review'] },\n { char: '🔥', name: 'fire', group: 'Reactions', keywords: ['hot', 'good'] },\n { char: '✅', name: 'check mark', group: 'Reactions', keywords: ['done', 'pass'] },\n { char: '❌', name: 'cross mark', group: 'Reactions', keywords: ['no', 'fail'] },\n { char: '⚠️', name: 'warning', group: 'Reactions', keywords: ['careful', 'risk'] },\n\n { char: '😀', name: 'grinning', group: 'Smileys', keywords: ['happy', 'smile'] },\n { char: '😂', name: 'tears of joy', group: 'Smileys', keywords: ['laugh', 'lol'] },\n { char: '🙂', name: 'slight smile', group: 'Smileys' },\n { char: '😅', name: 'sweat smile', group: 'Smileys', keywords: ['phew'] },\n { char: '🤔', name: 'thinking', group: 'Smileys', keywords: ['hmm', 'consider'] },\n { char: '😍', name: 'heart eyes', group: 'Smileys', keywords: ['love'] },\n { char: '😢', name: 'crying', group: 'Smileys', keywords: ['sad'] },\n { char: '😴', name: 'sleeping', group: 'Smileys', keywords: ['tired', 'zzz'] },\n { char: '🤯', name: 'mind blown', group: 'Smileys', keywords: ['wow'] },\n { char: '🫠', name: 'melting face', group: 'Smileys', keywords: ['hot', 'stress'] },\n\n { char: '💻', name: 'laptop', group: 'Work', keywords: ['code', 'dev'] },\n { char: '🐛', name: 'bug', group: 'Work', keywords: ['issue', 'defect'] },\n { char: '📦', name: 'package', group: 'Work', keywords: ['release', 'ship', 'npm'] },\n { char: '🧪', name: 'test tube', group: 'Work', keywords: ['test', 'experiment'] },\n { char: '📈', name: 'chart up', group: 'Work', keywords: ['growth', 'metrics'] },\n { char: '📉', name: 'chart down', group: 'Work', keywords: ['drop', 'regression'] },\n { char: '🔧', name: 'wrench', group: 'Work', keywords: ['fix', 'config'] },\n { char: '🔒', name: 'locked', group: 'Work', keywords: ['security', 'private'] },\n { char: '📝', name: 'memo', group: 'Work', keywords: ['note', 'docs'] },\n { char: '⏰', name: 'alarm clock', group: 'Work', keywords: ['deadline', 'time'] },\n\n { char: '☕', name: 'coffee', group: 'Life', keywords: ['break'] },\n { char: '🍕', name: 'pizza', group: 'Life', keywords: ['food'] },\n { char: '🌧️', name: 'rain', group: 'Life', keywords: ['weather'] },\n { char: '🌞', name: 'sun', group: 'Life', keywords: ['weather', 'sunny'] },\n { char: '🐙', name: 'octopus', group: 'Life', keywords: ['git', 'octocat'] },\n { char: '🐈', name: 'cat', group: 'Life', keywords: ['pet'] },\n { char: '🎧', name: 'headphone', group: 'Life', keywords: ['music', 'focus'] },\n { char: '✈️', name: 'airplane', group: 'Life', keywords: ['travel', 'ooo'] },\n]\n\ntype EmojiPickerProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n emoji?: Emoji[]\n onSelect?: (emoji: Emoji) => void\n /** Characters, most recent first. Persisted by the caller, not by this. */\n recent?: string[]\n recentLabel?: string\n columns?: number\n searchable?: boolean\n searchPlaceholder?: string\n emptyLabel?: string\n /** Height of the scrolling area. */\n height?: number\n label?: string\n}\n\nfunction EmojiPicker({\n emoji = COMMON_EMOJI,\n onSelect,\n recent = [],\n recentLabel = 'Recent',\n columns = 8,\n searchable = true,\n searchPlaceholder = 'Search emoji',\n emptyLabel = 'No emoji matches.',\n height = 240,\n label = 'Emoji picker',\n className,\n ...props\n}: EmojiPickerProps) {\n const [query, setQuery] = useState('')\n const [active, setActive] = useState(0)\n const gridRef = useRef<HTMLDivElement>(null)\n\n const matches = useMemo(() => {\n const needle = query.trim().toLowerCase()\n if (!needle) return emoji\n return emoji.filter(\n (item) =>\n item.name.includes(needle) ||\n item.keywords?.some((word) => word.includes(needle)) ||\n item.char === needle,\n )\n }, [emoji, query])\n\n /** Recents first as their own group, then the natural grouping. */\n const groups = useMemo(() => {\n const out: { name: string; items: Emoji[] }[] = []\n if (!query && recent.length > 0) {\n const found = recent\n .map((char) => emoji.find((item) => item.char === char))\n .filter(Boolean) as Emoji[]\n if (found.length) out.push({ name: recentLabel, items: found })\n }\n for (const item of matches) {\n const bucket = out.find((group) => group.name === item.group && group.name !== recentLabel)\n if (bucket) bucket.items.push(item)\n else out.push({ name: item.group, items: [item] })\n }\n return out\n }, [matches, recent, emoji, query, recentLabel])\n\n /** Flat order, so arrow keys cross group boundaries the way the eye does. */\n const flat = useMemo(() => groups.flatMap((group) => group.items), [groups])\n\n function onKeyDown(event: React.KeyboardEvent) {\n const step =\n event.key === 'ArrowRight' ? 1\n : event.key === 'ArrowLeft' ? -1\n : event.key === 'ArrowDown' ? columns\n : event.key === 'ArrowUp' ? -columns\n : 0\n\n if (step === 0) {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n const picked = flat[active]\n if (picked) onSelect?.(picked)\n }\n return\n }\n\n event.preventDefault()\n const next = Math.min(Math.max(0, active + step), flat.length - 1)\n setActive(next)\n // Follow the focus, or arrowing past the fold looks like nothing happened.\n gridRef.current\n ?.querySelector<HTMLButtonElement>(`[data-index=\"${next}\"]`)\n ?.focus()\n }\n\n let index = -1\n\n return (\n <div\n data-slot=\"emoji-picker\"\n className={cn(surface, radius.surface, 'flex w-72 flex-col overflow-hidden', className)}\n {...props}\n >\n {searchable && (\n <div className=\"border-border border-b p-2\">\n <Input\n size=\"sm\"\n icon={<Search />}\n value={query}\n onChange={(event) => {\n setQuery(event.target.value)\n setActive(0)\n }}\n placeholder={searchPlaceholder}\n aria-label={searchPlaceholder}\n />\n </div>\n )}\n\n <div\n ref={gridRef}\n role=\"grid\"\n aria-label={label}\n style={{ height }}\n onKeyDown={onKeyDown}\n className=\"min-h-0 flex-1 overflow-y-auto p-2\"\n >\n {flat.length === 0 ? (\n <p className=\"text-muted-foreground p-2 text-xs\">{emptyLabel}</p>\n ) : (\n groups.map((group) => (\n <section key={group.name} className=\"mb-2 last:mb-0\">\n <h3 className=\"text-muted-foreground px-1 py-1 text-[11px] font-medium tracking-wide uppercase\">\n {group.name}\n </h3>\n <div\n role=\"row\"\n className=\"grid gap-0.5\"\n style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}\n >\n {group.items.map((item) => {\n index += 1\n const position = index\n return (\n <button\n key={`${group.name}-${item.char}`}\n type=\"button\"\n role=\"gridcell\"\n data-index={position}\n // Roving tabindex: one stop for the whole grid.\n tabIndex={position === active ? 0 : -1}\n aria-label={item.name}\n title={item.name}\n onFocus={() => setActive(position)}\n onClick={() => onSelect?.(item)}\n className={cn(\n 'flex aspect-square items-center justify-center text-lg leading-none',\n radius.xs,\n 'hover:bg-muted',\n focusRing,\n )}\n >\n <span aria-hidden=\"true\">{item.char}</span>\n </button>\n )\n })}\n </div>\n </section>\n ))\n )}\n </div>\n </div>\n )\n}\n\nexport { EmojiPicker }\nexport type { EmojiPickerProps }\n"
|
|
19
20
|
}
|
|
20
21
|
]
|
|
21
22
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
11
|
"button",
|
|
12
|
+
"input",
|
|
12
13
|
"lib-styles",
|
|
13
14
|
"lib-utils"
|
|
14
15
|
],
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
{
|
|
17
18
|
"path": "components/ui/event-stream.tsx",
|
|
18
19
|
"type": "registry:ui",
|
|
19
|
-
"content": "import { useEffect, useId, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Pause, Play } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport {
|
|
20
|
+
"content": "import { useEffect, useId, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Pause, Play } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A live tail of analytics events, newest first.\n *\n * **The debugging tool for the tracking itself.** Funnels and dashboards show\n * what was recorded; this shows what is *arriving*, right now, with its\n * properties — which is the only way to answer \"did that click fire the event,\n * and did it carry the right payload\" without opening a network panel.\n *\n * **Autoscroll pauses when you scroll away, and resumes at the top.** A stream\n * that yanks you back to the newest row every time one arrives makes reading\n * any event impossible — the thing you were looking at is gone before you\n * finish. Scroll position is the intent signal, so no toggle is needed for the\n * common case, and the explicit pause button exists for when the rate is high\n * enough that even hovering is a fight.\n *\n * **The list is capped and drops the oldest.** An unbounded live list is a\n * memory leak with a UI: at a few hundred events per minute a tab left open\n * over lunch is tens of thousands of nodes. `limit` is the ceiling, and the\n * count of what was dropped is shown rather than silently forgotten.\n *\n * Rendering is newest-first with `aria-live=\"off\"` deliberately: announcing\n * every arriving event would make a screen reader unusable. The count is\n * announced politely instead.\n */\nexport type StreamEvent = {\n id: string\n name: string\n at: Date | string\n /** Shown expanded under the row. */\n properties?: Record<string, unknown>\n /** Colours the dot — 'error', 'page', whatever you group by. */\n kind?: string\n user?: ReactNode\n}\n\ntype EventStreamProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n events: StreamEvent[]\n /** Rows kept. The oldest beyond this are dropped. */\n limit?: number\n height?: number\n /** Filter box over event names. */\n filterable?: boolean\n onSelect?: (event: StreamEvent) => void\n /** Kinds mapped to a CSS colour. */\n colorFor?: (kind: string | undefined) => string\n paused?: boolean\n onPausedChange?: (paused: boolean) => void\n timeFormat?: (date: Date) => string\n emptyLabel?: string\n filterPlaceholder?: string\n label?: string\n}\n\nconst asDate = (value: Date | string) => (value instanceof Date ? value : new Date(value))\n\nconst DEFAULT_TIME: (date: Date) => string = (date: Date) =>\n date.toLocaleTimeString(undefined, { hour12: false }) +\n '.' +\n String(date.getMilliseconds()).padStart(3, '0')\n\nfunction EventStream({\n events,\n limit = 500,\n height = 360,\n filterable = true,\n onSelect,\n colorFor,\n paused: pausedProp,\n onPausedChange,\n timeFormat = DEFAULT_TIME,\n emptyLabel = 'Waiting for events…',\n filterPlaceholder = 'Filter by name',\n label = 'Event stream',\n className,\n ...props\n}: EventStreamProps) {\n const titleId = useId()\n const listRef = useRef<HTMLDivElement>(null)\n const [query, setQuery] = useState('')\n const [expanded, setExpanded] = useState<string | null>(null)\n const [internalPaused, setInternalPaused] = useState(false)\n /** Set when the reader has scrolled away from the top. */\n const [stuck, setStuck] = useState(false)\n\n const paused = pausedProp ?? internalPaused\n\n const setPaused = (next: boolean) => {\n if (pausedProp === undefined) setInternalPaused(next)\n onPausedChange?.(next)\n }\n\n const shown = useMemo(() => {\n const needle = query.trim().toLowerCase()\n const filtered = needle\n ? events.filter((event) => event.name.toLowerCase().includes(needle))\n : events\n // Newest first, then capped.\n return filtered.slice(0, limit)\n }, [events, query, limit])\n\n const dropped = Math.max(0, events.length - limit)\n\n useEffect(() => {\n const list = listRef.current\n if (!list || paused || stuck) return\n // Only when the reader is already at the top — otherwise the row being read\n // is scrolled out from under them.\n list.scrollTop = 0\n }, [shown.length, paused, stuck])\n\n const tint = (kind: string | undefined) =>\n colorFor?.(kind) ??\n (kind === 'error'\n ? 'var(--destructive)'\n : kind === 'page'\n ? 'var(--blue-soft-foreground)'\n : 'var(--muted-foreground)')\n\n return (\n <div\n data-slot=\"event-stream\"\n className={cn(surface, radius.surface, 'flex flex-col overflow-hidden', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <div className=\"border-border flex items-center gap-2 border-b px-3 py-2\">\n <p id={titleId} className=\"text-sm font-medium\">\n {label}\n </p>\n <span\n className=\"text-muted-foreground text-xs tabular-nums\"\n // The count is announced; the rows themselves are not.\n aria-live=\"polite\"\n >\n {shown.length}\n {dropped > 0 && ` (+${dropped} dropped)`}\n </span>\n\n <span className=\"flex-1\" />\n\n {filterable && (\n <Input\n size=\"xs\"\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={filterPlaceholder}\n aria-label={filterPlaceholder}\n containerClassName=\"w-40\"\n />\n )}\n\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={paused ? 'Resume' : 'Pause'}\n aria-pressed={paused}\n onClick={() => setPaused(!paused)}\n >\n {paused ? <Play /> : <Pause />}\n </Button>\n </div>\n\n <div\n ref={listRef}\n style={{ height }}\n // Never announce arrivals: at any real rate it makes a screen reader\n // unusable.\n aria-live=\"off\"\n onScroll={(event) => setStuck(event.currentTarget.scrollTop > 8)}\n className=\"min-h-0 flex-1 overflow-y-auto font-mono text-xs\"\n >\n {shown.length === 0 ? (\n <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>\n ) : (\n <ul className=\"list-none\">\n {shown.map((event) => {\n const open = expanded === event.id\n return (\n <li key={event.id} className=\"border-border/60 border-b last:border-b-0\">\n <button\n type=\"button\"\n onClick={() => {\n setExpanded(open ? null : event.id)\n onSelect?.(event)\n }}\n className=\"hover:bg-muted flex w-full items-center gap-2 px-3 py-1.5 text-start\"\n >\n <span\n aria-hidden=\"true\"\n className=\"size-1.5 shrink-0 rounded-full\"\n style={{ background: tint(event.kind) }}\n />\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {timeFormat(asDate(event.at))}\n </span>\n <span className=\"min-w-0 flex-1 truncate font-medium\">{event.name}</span>\n {event.user && (\n <span className=\"text-muted-foreground shrink-0 truncate\">{event.user}</span>\n )}\n </button>\n\n {open && event.properties && (\n <pre className=\"bg-muted/50 text-muted-foreground overflow-x-auto px-3 py-2 text-[11px] whitespace-pre-wrap\">\n {JSON.stringify(event.properties, null, 2)}\n </pre>\n )}\n </li>\n )\n })}\n </ul>\n )}\n </div>\n\n {stuck && !paused && (\n <button\n type=\"button\"\n onClick={() => {\n listRef.current?.scrollTo({ top: 0 })\n setStuck(false)\n }}\n className=\"border-border text-muted-foreground hover:bg-muted border-t px-3 py-1.5 text-xs\"\n >\n Autoscroll paused — jump to newest\n </button>\n )}\n </div>\n )\n}\n\nexport { EventStream }\nexport type { EventStreamProps }\n"
|
|
20
21
|
}
|
|
21
22
|
]
|
|
22
23
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "gantt",
|
|
3
3
|
"type": "registry:ui",
|
|
4
4
|
"title": "Gantt",
|
|
5
|
-
"description": "Work on a timeline:
|
|
5
|
+
"description": "Work on a timeline: drag a bar to reschedule and its edges to change the start or end, with a computed critical path and dependency conflicts surfaced as you create them.",
|
|
6
6
|
"category": "Views",
|
|
7
7
|
"dependencies": [],
|
|
8
8
|
"registryDependencies": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"path": "components/ui/gantt.tsx",
|
|
15
15
|
"type": "registry:ui",
|
|
16
|
-
"content": "import { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { dataPalette } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Work on a timeline: bars for duration, lines for dependency.\n *\n * **The view `Kanban`, `Timeline` and `Stepper` cannot give you.** Kanban shows\n * state and hides time. A timeline shows moments, not spans. A stepper shows\n * order, not overlap. Only this shows *how long* and *at the same time as\n * what* — which is the entire question in scheduling.\n *\n * **The critical path is computed, not annotated.** Highlighting it by hand is\n * how a Gantt chart goes stale: someone slips a task, nobody re-marks the path,\n * and the chart now points at the wrong risk. Here the longest dependency chain\n * is derived from the data, so it follows the plan.\n *\n * **Dependencies are drawn as finish-to-start links** and a violated one — a\n * task starting before what it depends on ends — is drawn in the danger colour\n * rather than quietly rendered as a line going backwards. An impossible\n * schedule should look impossible.\n *\n * Dates are laid out on a linear day scale between the earliest start and the\n * latest end, so the width of a bar is proportional to its duration. Rows are\n * plain elements with the bar positioned by percentage: no measurement, and it\n * survives a resize.\n */\nexport type GanttTask = {\n id: string\n label: ReactNode\n start: Date | string\n end: Date | string\n /** 0–1. Drawn as a fill inside the bar. */\n progress?: number\n /** Ids this task cannot start before. */\n dependsOn?: string[]\n group?: string\n color?: string\n milestone?: boolean\n}\n\ntype GanttProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n tasks: GanttTask[]\n onSelect?: (task: GanttTask) => void\n /** Derive and highlight the longest dependency chain. */\n criticalPath?: boolean\n /** Column width for the task names. */\n labelWidth?: number\n rowHeight?: number\n /** Formats the axis ticks. */\n dateFormat?: (date: Date) => string\n /** Number of ticks along the top. */\n ticks?: number\n /** Drawn as a vertical marker. Defaults to no marker. */\n today?: Date\n emptyLabel?: string\n label?: string\n}\n\nconst DAY = 86_400_000\nconst asDate = (value: Date | string) => (value instanceof Date ? value : new Date(value))\n\nconst DEFAULT_DATE_FORMAT: (date: Date) => string = (date: Date) =>\n date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n\nfunction Gantt({\n tasks,\n onSelect,\n criticalPath = true,\n labelWidth = 160,\n rowHeight = 30,\n dateFormat = DEFAULT_DATE_FORMAT,\n ticks = 5,\n today,\n emptyLabel = 'Nothing scheduled.',\n label = 'Gantt chart',\n className,\n ...props\n}: GanttProps) {\n const titleId = useId()\n\n const model = useMemo(() => {\n if (tasks.length === 0) return null\n\n const rows = tasks.map((task) => ({\n task,\n start: asDate(task.start).getTime(),\n end: asDate(task.end).getTime(),\n }))\n\n const first = Math.min(...rows.map((row) => row.start))\n const last = Math.max(...rows.map((row) => row.end))\n const span = last - first || DAY\n\n /**\n * Critical path: the longest chain by duration, walked backwards from\n * whichever task finishes last.\n */\n const byId = new Map(rows.map((row) => [row.task.id, row]))\n const critical = new Set<string>()\n\n if (criticalPath) {\n const memo = new Map<string, number>()\n const seen = new Set<string>()\n\n const longest = (id: string): number => {\n if (memo.has(id)) return memo.get(id) as number\n // A dependency cycle has no longest path; stop rather than recurse.\n if (seen.has(id)) return 0\n seen.add(id)\n const row = byId.get(id)\n if (!row) return 0\n const duration = row.end - row.start\n const parents = row.task.dependsOn ?? []\n const best = parents.length ? Math.max(...parents.map((parent) => longest(parent))) : 0\n const value = best + duration\n memo.set(id, value)\n return value\n }\n\n for (const row of rows) longest(row.task.id)\n let cursor = [...memo.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]\n while (cursor) {\n critical.add(cursor)\n const parents = byId.get(cursor)?.task.dependsOn ?? []\n cursor = parents.sort((a, b) => (memo.get(b) ?? 0) - (memo.get(a) ?? 0))[0]\n }\n }\n\n // A dependent that starts before its predecessor ends is impossible.\n const violated = new Set<string>()\n for (const row of rows) {\n for (const parent of row.task.dependsOn ?? []) {\n const before = byId.get(parent)\n if (before && row.start < before.end) violated.add(`${parent}->${row.task.id}`)\n }\n }\n\n return { rows, first, last, span, critical, violated, byId }\n }, [tasks, criticalPath])\n\n if (!model) {\n return (\n <div className={cn('text-muted-foreground p-4 text-xs', className)} {...props}>\n {emptyLabel}\n </div>\n )\n }\n\n const { rows, first, span, critical, violated } = model\n const at = (time: number) => ((time - first) / span) * 100\n\n const axis = Array.from({ length: ticks + 1 }, (_, index) => first + (span * index) / ticks)\n\n return (\n <div\n data-slot=\"gantt\"\n className={cn('w-full overflow-x-auto', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n\n <div className=\"min-w-[32rem]\">\n <div className=\"flex items-end\" style={{ paddingInlineStart: labelWidth }}>\n {axis.map((time, index) => (\n <span\n key={index}\n className=\"text-muted-foreground flex-1 text-[10px] first:text-start last:text-end\"\n >\n {dateFormat(new Date(time))}\n </span>\n ))}\n </div>\n\n <div className=\"relative mt-1\">\n {/* Today marker, behind the bars. */}\n {today && (\n <span\n aria-hidden=\"true\"\n className=\"bg-[var(--destructive)] absolute top-0 bottom-0 z-10 w-px opacity-60\"\n style={{\n insetInlineStart: `calc(${labelWidth}px + (100% - ${labelWidth}px) * ${at(today.getTime()) / 100})`,\n }}\n />\n )}\n\n {rows.map(({ task, start, end }) => {\n const onPath = critical.has(task.id)\n const colour = task.color ?? dataPalette[0].fill\n const left = at(start)\n const width = Math.max(task.milestone ? 0 : 0.6, at(end) - at(start))\n\n return (\n <div key={task.id} className=\"flex items-center\" style={{ height: rowHeight }}>\n <span\n className=\"text-foreground shrink-0 truncate pe-3 text-xs\"\n style={{ width: labelWidth }}\n >\n {task.label}\n </span>\n\n <div className=\"relative h-full flex-1\">\n <button\n type=\"button\"\n disabled={!onSelect}\n onClick={() => onSelect?.(task)}\n title={`${asDate(task.start).toLocaleDateString()} – ${asDate(task.end).toLocaleDateString()}${\n onPath ? ' (critical path)' : ''\n }`}\n className={cn(\n 'absolute top-1/2 -translate-y-1/2 overflow-hidden',\n task.milestone ? 'size-3 rotate-45 rounded-[2px]' : 'h-4 rounded-[3px]',\n onSelect ? 'cursor-pointer' : 'cursor-default',\n onPath && 'ring-1 ring-[var(--destructive)] ring-offset-1',\n )}\n style={{\n insetInlineStart: `${left}%`,\n width: task.milestone ? undefined : `${width}%`,\n background: colour,\n opacity: 0.85,\n }}\n >\n {task.progress !== undefined && !task.milestone && (\n <span\n aria-hidden=\"true\"\n className=\"absolute inset-y-0 start-0 bg-black/25\"\n style={{ width: `${Math.min(100, Math.max(0, task.progress * 100))}%` }}\n />\n )}\n </button>\n </div>\n </div>\n )\n })}\n </div>\n\n {violated.size > 0 && (\n <p role=\"status\" className=\"text-[var(--destructive)] mt-2 text-[11px]\">\n {violated.size} dependency {violated.size === 1 ? 'conflict' : 'conflicts'}: a task starts\n before what it depends on finishes.\n </p>\n )}\n </div>\n </div>\n )\n}\n\nexport { Gantt }\nexport type { GanttProps }\n"
|
|
16
|
+
"content": "import {\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Work on a timeline: bars for duration, dragged to reschedule.\n *\n * **The view `Kanban`, `Timeline` and `Stepper` cannot give you.** Kanban shows\n * state and hides time. A timeline shows moments, not spans. A stepper shows\n * order, not overlap. Only this shows *how long* and *at the same time as\n * what* — which is the entire question in scheduling.\n *\n * **A read-only Gantt is a picture of a plan, and plans change.** Dragging is\n * not decoration here: the whole reason to look at this chart is to notice that\n * something has slipped, and the useful next action is to move it. A bar is\n * dragged to reschedule, and its edges are dragged to change the start or the\n * end independently.\n *\n * **Everything snaps, and the snap is the unit the plan is kept in.** Dates\n * dragged to arbitrary millisecond boundaries produce a plan nobody can read\n * back; `snapMinutes` defaults to a day, which is the granularity most plans\n * are actually managed at.\n *\n * **Dragging is not the only way to move a task.** A bar is a real button:\n * arrow keys move it a snap at a time, shift-arrow changes its end, and both\n * announce through `aria-valuetext`. A chart that can only be edited with a\n * pointer cannot be edited at all by a good number of people.\n *\n * **It is monochrome, and meaning is carried by shape rather than hue.** A\n * plan is read for structure — what is long, what overlaps, what is late — and\n * a palette assigning arbitrary colours to tasks adds a legend to learn without\n * adding information. It also means the two states that matter are not encoded\n * in colour alone, which is the usual accessibility failure: the critical path\n * is ringed and a dependency conflict is outlined in dashes, both legible to\n * anyone who cannot separate red from grey.\n *\n * **The critical path is computed, not annotated.** Highlighting it by hand is\n * how a Gantt goes stale: someone slips a task, nobody re-marks the path, and\n * the chart now points at the wrong risk. Dependency conflicts — a task\n * starting before what it depends on ends — are surfaced as you create them,\n * because an impossible schedule should look impossible while you are making\n * it, not after you save.\n */\nexport type GanttTask = {\n id: string\n label: ReactNode\n start: Date | string\n end: Date | string\n /** 0–1. Drawn as a fill inside the bar. */\n progress?: number\n /** Ids this task cannot start before. */\n dependsOn?: string[]\n group?: string\n milestone?: boolean\n /** Excluded from dragging and from keyboard editing. */\n locked?: boolean\n}\n\ntype GanttProps = Omit<ComponentProps<'div'>, 'onSelect' | 'onChange'> & {\n tasks: GanttTask[]\n /** The whole list after an edit. Omit to make the chart read-only. */\n onChange?: (tasks: GanttTask[]) => void\n /** Just the task that moved, for a targeted PATCH. */\n onTaskChange?: (task: GanttTask, previous: GanttTask) => void\n onSelect?: (task: GanttTask) => void\n selectedId?: string\n /** Grid the drag snaps to. A day by default. */\n snapMinutes?: number\n /** Derive and highlight the longest dependency chain. */\n criticalPath?: boolean\n /** Column width for the task names. */\n labelWidth?: number\n rowHeight?: number\n dateFormat?: (date: Date) => string\n /** Number of ticks along the top. */\n ticks?: number\n today?: Date\n emptyLabel?: string\n label?: string\n}\n\nconst MINUTE = 60_000\nconst asDate = (value: Date | string) => (value instanceof Date ? value : new Date(value))\nconst DEFAULT_DATE_FORMAT: (date: Date) => string = (date: Date) =>\n date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n\ntype Mode = 'move' | 'start' | 'end'\ntype Drag = { id: string; mode: Mode; pointerX: number; start: number; end: number }\n\nfunction Gantt({\n tasks,\n onChange,\n onTaskChange,\n onSelect,\n selectedId,\n snapMinutes = 1440,\n criticalPath = true,\n labelWidth = 160,\n rowHeight = 34,\n dateFormat = DEFAULT_DATE_FORMAT,\n ticks = 5,\n today,\n emptyLabel = 'Nothing scheduled.',\n label = 'Gantt chart',\n className,\n ...props\n}: GanttProps) {\n const titleId = useId()\n const trackRef = useRef<HTMLDivElement>(null)\n const drag = useRef<Drag | null>(null)\n /** The task being dragged, as it will be if released now. */\n const [preview, setPreview] = useState<{ id: string; start: number; end: number } | null>(null)\n\n const editable = Boolean(onChange || onTaskChange)\n\n const model = useMemo(() => {\n if (tasks.length === 0) return null\n\n const rows = tasks.map((task) => ({\n task,\n start: asDate(task.start).getTime(),\n end: asDate(task.end).getTime(),\n }))\n\n // The window is padded by one snap either side, so a task can always be\n // dragged past the current extremes rather than hitting an invisible wall.\n const pad = snapMinutes * MINUTE\n const first = Math.min(...rows.map((row) => row.start)) - pad\n const last = Math.max(...rows.map((row) => row.end)) + pad\n\n const byId = new Map(rows.map((row) => [row.task.id, row]))\n const critical = new Set<string>()\n\n if (criticalPath) {\n const memo = new Map<string, number>()\n const open = new Set<string>()\n const longest = (id: string): number => {\n if (memo.has(id)) return memo.get(id) as number\n // A dependency cycle has no longest path; stop rather than recurse.\n if (open.has(id)) return 0\n open.add(id)\n const row = byId.get(id)\n if (!row) return 0\n const parents = row.task.dependsOn ?? []\n const best = parents.length ? Math.max(...parents.map(longest)) : 0\n const value = best + (row.end - row.start)\n memo.set(id, value)\n return value\n }\n for (const row of rows) longest(row.task.id)\n let cursor = [...memo.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]\n while (cursor) {\n critical.add(cursor)\n const parents = byId.get(cursor)?.task.dependsOn ?? []\n cursor = parents.sort((a, b) => (memo.get(b) ?? 0) - (memo.get(a) ?? 0))[0]\n }\n }\n\n return { rows, first, last, span: last - first || 1, critical, byId }\n }, [tasks, criticalPath, snapMinutes])\n\n /** Where a task sits right now, preferring the in-flight drag. */\n const timesOf = useCallback(\n (id: string, fallback: { start: number; end: number }) =>\n preview?.id === id ? { start: preview.start, end: preview.end } : fallback,\n [preview],\n )\n\n /** Conflicts recomputed against the drag, so they appear as you create them. */\n const conflicts = useMemo(() => {\n if (!model) return new Set<string>()\n const out = new Set<string>()\n for (const row of model.rows) {\n const self = timesOf(row.task.id, row)\n for (const parent of row.task.dependsOn ?? []) {\n const other = model.byId.get(parent)\n if (!other) continue\n if (self.start < timesOf(parent, other).end) out.add(row.task.id)\n }\n }\n return out\n }, [model, timesOf])\n\n /**\n * Latest values for the window listeners.\n *\n * The listeners are bound once, not on every render: re-binding them each\n * time works but adds and removes two listeners per frame of a drag. Reading\n * through a ref keeps them current without making them a dependency.\n */\n const latest = useRef({ tasks, model, snapMinutes, onChange, onTaskChange })\n\n const commit = (id: string, start: number, end: number) => {\n const previous = tasks.find((task) => task.id === id)\n if (!previous) return\n const next: GanttTask = { ...previous, start: new Date(start), end: new Date(end) }\n onTaskChange?.(next, previous)\n onChange?.(tasks.map((task) => (task.id === id ? next : task)))\n }\n\n latest.current = { tasks, model, snapMinutes, onChange, onTaskChange }\n\n const beginDrag = (event: ReactPointerEvent, id: string, mode: Mode) => {\n if (!editable) return\n const row = model?.byId.get(id)\n if (!row || row.task.locked) return\n\n event.preventDefault()\n event.stopPropagation()\n try {\n ;(event.currentTarget as Element).setPointerCapture(event.pointerId)\n } catch {\n // A pointer that is no longer active cannot be captured.\n }\n drag.current = { id, mode, pointerX: event.clientX, start: row.start, end: row.end }\n setPreview({ id, start: row.start, end: row.end })\n }\n\n useEffect(() => {\n if (!editable) return\n\n const currentMsPerPixel = () => {\n const width = trackRef.current?.clientWidth ?? 0\n const current = latest.current.model\n return width > 0 && current ? current.span / width : 0\n }\n const currentSnap = (time: number) => {\n const step = latest.current.snapMinutes * MINUTE\n return Math.round(time / step) * step\n }\n const currentCommit = (id: string, start: number, end: number) => {\n const list = latest.current.tasks\n const previous = list.find((task) => task.id === id)\n if (!previous) return\n const next: GanttTask = { ...previous, start: new Date(start), end: new Date(end) }\n latest.current.onTaskChange?.(next, previous)\n latest.current.onChange?.(list.map((task) => (task.id === id ? next : task)))\n }\n\n /*\n * Move and release are bound to the window, not the bar.\n *\n * A pointer that leaves the element mid-drag — which is most of them, since\n * the whole point is to travel — would otherwise stop updating, and a\n * release outside would never commit, leaving the bar stuck to the cursor.\n */\n const onMove = (event: PointerEvent) => {\n const state = drag.current\n if (!state) return\n\n const delta = (event.clientX - state.pointerX) * currentMsPerPixel()\n const duration = state.end - state.start\n const step = latest.current.snapMinutes * MINUTE\n\n if (state.mode === 'move') {\n const start = currentSnap(state.start + delta)\n setPreview({ id: state.id, start, end: start + duration })\n } else if (state.mode === 'start') {\n // A start dragged past the end would invert the bar; clamp to one snap.\n const start = Math.min(currentSnap(state.start + delta), state.end - step)\n setPreview({ id: state.id, start, end: state.end })\n } else {\n const end = Math.max(currentSnap(state.end + delta), state.start + step)\n setPreview({ id: state.id, start: state.start, end })\n }\n }\n\n const onUp = () => {\n const state = drag.current\n drag.current = null\n setPreview((current) => {\n if (state && current && current.id === state.id) {\n if (current.start !== state.start || current.end !== state.end) {\n currentCommit(current.id, current.start, current.end)\n }\n }\n return null\n })\n }\n\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', onUp)\n window.addEventListener('pointercancel', onUp)\n return () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', onUp)\n window.removeEventListener('pointercancel', onUp)\n }\n }, [editable])\n\n if (!model) {\n return (\n <div className={cn('text-muted-foreground p-4 text-xs', className)} {...props}>\n {emptyLabel}\n </div>\n )\n }\n\n const { rows, first, span, critical } = model\n const at = (time: number) => ((time - first) / span) * 100\n const axis = Array.from({ length: ticks + 1 }, (_, index) => first + (span * index) / ticks)\n\n return (\n <div\n data-slot=\"gantt\"\n className={cn('w-full overflow-x-auto', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n\n <div className=\"min-w-[34rem]\">\n <div className=\"flex items-end\" style={{ paddingInlineStart: labelWidth }}>\n {axis.map((time, index) => (\n <span\n key={index}\n className=\"text-muted-foreground flex-1 text-[10px] first:text-start last:text-end\"\n >\n {dateFormat(new Date(time))}\n </span>\n ))}\n </div>\n\n <div className=\"relative mt-1\">\n {today && (\n <span\n aria-hidden=\"true\"\n className=\"bg-foreground absolute top-0 bottom-0 z-10 w-px opacity-40\"\n style={{\n insetInlineStart: `calc(${labelWidth}px + (100% - ${labelWidth}px) * ${\n at(today.getTime()) / 100\n })`,\n }}\n />\n )}\n\n {rows.map(({ task, start: baseStart, end: baseEnd }) => {\n const { start, end } = timesOf(task.id, { start: baseStart, end: baseEnd })\n const onPath = critical.has(task.id)\n const broken = conflicts.has(task.id)\n // Weight, not hue: a bar on the critical path is simply denser.\n const density = onPath ? 0.92 : 0.7\n const dragging = preview?.id === task.id\n const width = Math.max(task.milestone ? 0 : 0.6, at(end) - at(start))\n\n const readable = `${asDate(new Date(start)).toLocaleDateString()} – ${asDate(\n new Date(end),\n ).toLocaleDateString()}`\n\n return (\n <div key={task.id} className=\"flex items-center\" style={{ height: rowHeight }}>\n <span\n className=\"text-foreground shrink-0 truncate pe-3 text-xs\"\n style={{ width: labelWidth }}\n >\n {task.label}\n </span>\n\n <div ref={trackRef} className=\"relative h-full flex-1\">\n <div\n role={editable && !task.locked ? 'slider' : undefined}\n tabIndex={editable && !task.locked ? 0 : undefined}\n aria-label={\n typeof task.label === 'string' ? task.label : `Task ${task.id}`\n }\n aria-valuetext={readable}\n aria-invalid={broken || undefined}\n title={`${readable}${onPath ? ' · critical path' : ''}${\n broken ? ' · starts before a dependency ends' : ''\n }`}\n onPointerDown={(event) => beginDrag(event, task.id, 'move')}\n onClick={() => onSelect?.(task)}\n onKeyDown={(event) => {\n if (!editable || task.locked) return\n const step = snapMinutes * MINUTE\n const direction =\n event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0\n if (!direction) return\n event.preventDefault()\n // Shift resizes; plain arrows move. Both by one snap, so\n // the keyboard produces the same values a drag can.\n if (event.shiftKey) {\n commit(task.id, start, Math.max(start + step, end + direction * step))\n } else {\n commit(task.id, start + direction * step, end + direction * step)\n }\n }}\n className={cn(\n 'group absolute top-1/2 -translate-y-1/2 overflow-visible',\n task.milestone ? 'size-3 rotate-45 rounded-[2px]' : 'h-5 rounded-[3px]',\n editable && !task.locked ? 'cursor-grab' : 'cursor-default',\n dragging && 'cursor-grabbing shadow-lg',\n onPath && 'ring-foreground ring-1 ring-offset-1',\n // Dashed, not red: the state has to survive being printed\n // in grey or read by someone who cannot see the hue.\n broken && 'outline-foreground outline-2 outline-dashed',\n focusRing,\n )}\n style={{\n insetInlineStart: `${at(start)}%`,\n width: task.milestone ? undefined : `${width}%`,\n background: 'var(--foreground)',\n opacity: dragging ? 1 : density,\n }}\n >\n {task.progress !== undefined && !task.milestone && (\n <span\n aria-hidden=\"true\"\n className=\"bg-background/35 absolute inset-y-0 start-0 rounded-s-[3px]\"\n style={{ width: `${Math.min(100, Math.max(0, task.progress * 100))}%` }}\n />\n )}\n\n {/* Edge handles. Wider than they look — an 8px target is\n the difference between resizing and moving by accident. */}\n {editable && !task.locked && !task.milestone && (\n <>\n <span\n role=\"presentation\"\n onPointerDown={(event) => beginDrag(event, task.id, 'start')}\n className={cn(\n 'absolute inset-y-0 -start-1 w-2 cursor-ew-resize',\n radius.xs,\n 'opacity-0 group-hover:bg-white/60 group-hover:opacity-100',\n )}\n />\n <span\n role=\"presentation\"\n onPointerDown={(event) => beginDrag(event, task.id, 'end')}\n className={cn(\n 'absolute inset-y-0 -end-1 w-2 cursor-ew-resize',\n radius.xs,\n 'opacity-0 group-hover:bg-white/60 group-hover:opacity-100',\n )}\n />\n </>\n )}\n </div>\n </div>\n </div>\n )\n })}\n </div>\n\n {conflicts.size > 0 && (\n <p role=\"status\" className=\"text-foreground mt-2 text-[11px] font-medium\">\n {conflicts.size} dependency {conflicts.size === 1 ? 'conflict' : 'conflicts'}: a task\n starts before what it depends on finishes.\n </p>\n )}\n </div>\n </div>\n )\n}\n\nexport { Gantt }\nexport type { GanttProps }\n"
|
|
17
17
|
}
|
|
18
18
|
]
|
|
19
19
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"path": "components/ui/input-file.tsx",
|
|
22
22
|
"type": "registry:ui",
|
|
23
|
-
"content": "import { useId, useRef, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Paperclip, Upload } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Spinner } from '@/components/ui/spinner'\nimport { UploadList } from '@/components/ui/upload-list'\nimport type { Attachment } from '@/components/ui/attachment-preview'\nimport { useUploads, type FileUpload, type UploadHandler } from '@/lib/use-uploads'\nimport {\n disabledState,\n fieldBase,\n fieldSize,\n focusRing,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A file picker shaped like an Input, which also runs the upload.\n *\n * The counterpart to `Dropzone`, not a replacement: this is the control you put\n * on a settings form beside a Label, where a full drop card would be absurd.\n * Dropzone is the better default for a standalone uploader — dropping is the\n * primary gesture there, and it has room to say what is happening.\n *\n * The native control is replaced rather than restyled. `input[type=file]` gives\n * you a UA button whose text is not settable and whose layout is not reachable\n * except through a vendor pseudo-element, so matching the kit's field metrics\n * means driving a hidden input from our own trigger.\n *\n * The upload lives in `useUploads`, shared with Dropzone, so the two shapes\n * cannot drift in behaviour.\n */\ntype InputFileProps = Omit<\n ComponentProps<'input'>,\n 'size' | 'type' | 'value' | 'onChange' | 'onSelect'\n> & {\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n /** Text shown when nothing is picked. */\n placeholder?: string\n buttonLabel?: ReactNode\n clearLabel?: ReactNode\n /** Show the per-file rows under the field. */\n showList?: boolean\n /**\n * Runs the upload. Resolve to succeed — the value lands on `upload.result`;\n * throw to fail, and the message shows on the row with a retry.\n */\n onUpload?: UploadHandler\n /** Fires on selection, before any upload starts. */\n onSelect?: (uploads: FileUpload[]) => void\n /** Fires on every transition — start, progress, finish, failure, removal. */\n onUploadsChange?: (uploads: FileUpload[]) => void\n /** Reject anything larger, in bytes, before the upload starts. */\n maxSize?: number\n maxSizeLabel?: (limit: string) => string\n /** Force the busy state from outside, when the request is yours. */\n isUploading?: boolean\n /** Names the file being sent. */\n uploadingLabel?: (name: string) => string\n}\n\nconst VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent hover:bg-accent',\n} as const\n\n/**\n * The trigger, sized from the field rather than from the button scale.\n *\n * Two rules, both derived from the box it sits in: it is 4px shorter than the\n * field, so the inset above and below matches the trailing one; and its radius\n * is half its own height, which is the rule every control in the kit follows.\n * `xs` is the exception the field itself makes — that size is a true pill\n * (`rounded-full [corner-shape:round]`), so its trigger is one too.\n *\n * Mapping straight onto `controlSize` is what produced the original bug: sizes\n * `xs` and `sm` both took the `xs` button, which is a pill by design, and a\n * lozenge inside a 16px-radius field reads as a mistake.\n */\nconst TRIGGER = {\n xs: { size: 'sm', className: 'h-6 px-2.5 rounded-full [corner-shape:round]' },\n sm: { size: 'sm', className: 'h-7 px-3 rounded-[14px]' },\n md: { size: 'sm', className: '' },\n lg: { size: 'default', className: '' },\n xl: { size: 'default', className: 'h-11 px-5 rounded-[22px]' },\n} as const\n\nfunction InputFile({\n size = 'md',\n variant = 'default',\n error = false,\n placeholder = 'No file selected',\n buttonLabel = 'Browse',\n clearLabel = 'Clear',\n showList = true,\n multiple = false,\n disabled = false,\n maxSize,\n maxSizeLabel,\n accept,\n isUploading,\n uploadingLabel = (name) => `Uploading ${name}…`,\n className,\n onUpload,\n onSelect,\n onUploadsChange,\n ...props\n}: InputFileProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const id = useId()\n\n const { uploads, select, remove, retry, clear, uploading, failed, done } = useUploads({\n onUpload,\n multiple,\n maxSize,\n maxSizeLabel,\n accept,\n onSelect,\n onUploadsChange,\n })\n\n const busy = isUploading ?? uploading\n const inFlight = uploads.find((upload) => upload.status === 'uploading')\n\n let summary: ReactNode = placeholder\n if (busy) {\n summary = uploadingLabel(\n inFlight ? inFlight.name : `${uploads.length} file${uploads.length === 1 ? '' : 's'}`,\n )\n } else if (uploads.length === 1) summary = uploads[0].name\n else if (uploads.length > 1) summary = `${uploads.length} files`\n\n /** `UploadList` is presentational and takes `Attachment`, so map into it. */\n const attachments: Attachment[] = uploads.map((upload) => ({\n id: upload.id,\n name: upload.name,\n type: upload.type,\n size: upload.size,\n progress: upload.status === 'done' ? undefined : upload.progress,\n error: upload.error,\n }))\n\n return (\n <div className=\"flex w-full flex-col gap-2\">\n <div\n data-slot=\"input-file\"\n data-busy={busy}\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT[variant],\n (error || failed) && 'border-destructive',\n // The trailing inset belongs to text, not to a button. Every size\n // leaves 2px above and below the trigger, so the trailing side\n // matches it — the field's own text inset left a gap three times\n // that, and the button read as floating off the edge.\n 'pe-0.5',\n // The wrapper is the focus surface: focus lands on the hidden input,\n // so the ring is drawn from `focus-within` on the box around it.\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n {busy ? (\n <Spinner size=\"sm\" label=\"Uploading\" className=\"shrink-0\" />\n ) : done > 0 && !failed ? (\n <Check className=\"shrink-0 text-[var(--green-soft-foreground)]\" aria-hidden=\"true\" />\n ) : (\n <Paperclip className=\"text-muted-foreground shrink-0\" aria-hidden=\"true\" />\n )}\n\n <span\n id={id}\n className={cn(\n 'min-w-0 flex-1 truncate text-start',\n uploads.length === 0 && !busy && 'text-muted-foreground/70',\n )}\n >\n {summary}\n </span>\n\n {uploads.length > 0 && !busy && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size={TRIGGER[size].size}\n className={cn('shrink-0', TRIGGER[size].className)}\n onClick={() => {\n if (inputRef.current) inputRef.current.value = ''\n clear()\n }}\n >\n {clearLabel}\n </Button>\n )}\n\n <Button\n type=\"button\"\n variant=\"secondary\"\n size={TRIGGER[size].size}\n disabled={busy}\n className={cn('shrink-0', TRIGGER[size].className)}\n onClick={() => inputRef.current?.click()}\n >\n <Upload />\n {buttonLabel}\n </Button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled || busy}\n aria-labelledby={id}\n className={cn('sr-only', focusRing, disabledState, radius.control)}\n onChange={(event) => {\n select([...(event.target.files ?? [])])\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n {...props}\n />\n </div>\n\n {showList && uploads.length > 0 && (\n <UploadList uploads={attachments} onRemove={remove} onRetry={retry} />\n )}\n </div>\n )\n}\n\nexport { InputFile }\nexport type { InputFileProps }\n"
|
|
23
|
+
"content": "import { useId, useRef, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Paperclip, Upload } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Spinner } from '@/components/ui/spinner'\nimport { UploadList } from '@/components/ui/upload-list'\nimport type { Attachment } from '@/components/ui/attachment-preview'\nimport { useUploads, type FileUpload, type UploadHandler } from '@/lib/use-uploads'\nimport {\n disabledState,\n fieldBase,\n fieldSize,\n focusRing,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A file picker shaped like an Input, which also runs the upload.\n *\n * The counterpart to `Dropzone`, not a replacement: this is the control you put\n * on a settings form beside a Label, where a full drop card would be absurd.\n * Dropzone is the better default for a standalone uploader — dropping is the\n * primary gesture there, and it has room to say what is happening.\n *\n * The native control is replaced rather than restyled. `input[type=file]` gives\n * you a UA button whose text is not settable and whose layout is not reachable\n * except through a vendor pseudo-element, so matching the kit's field metrics\n * means driving a hidden input from our own trigger.\n *\n * The upload lives in `useUploads`, shared with Dropzone, so the two shapes\n * cannot drift in behaviour.\n */\ntype InputFileProps = Omit<\n ComponentProps<'input'>,\n 'size' | 'type' | 'value' | 'onChange' | 'onSelect'\n> & {\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n /** Text shown when nothing is picked. */\n placeholder?: string\n buttonLabel?: ReactNode\n clearLabel?: ReactNode\n /** Show the per-file rows under the field. */\n showList?: boolean\n /**\n * Runs the upload. Resolve to succeed — the value lands on `upload.result`;\n * throw to fail, and the message shows on the row with a retry.\n */\n onUpload?: UploadHandler\n /** Fires on selection, before any upload starts. */\n onSelect?: (uploads: FileUpload[]) => void\n /** Fires on every transition — start, progress, finish, failure, removal. */\n onUploadsChange?: (uploads: FileUpload[]) => void\n /** Reject anything larger, in bytes, before the upload starts. */\n maxSize?: number\n maxSizeLabel?: (limit: string) => string\n /** Force the busy state from outside, when the request is yours. */\n isUploading?: boolean\n /** Names the file being sent. */\n uploadingLabel?: (name: string) => string\n}\n\nconst VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent hover:bg-accent',\n} as const\n\n/**\n * The trigger, sized from the field rather than from the button scale.\n *\n * Two rules, both derived from the box it sits in: it is 4px shorter than the\n * field, so the inset above and below matches the trailing one; and its radius\n * is half its own height, which is the rule every control in the kit follows.\n * `xs` is the exception the field itself makes — that size is a true pill\n * (`rounded-full [corner-shape:round]`), so its trigger is one too.\n *\n * Mapping straight onto `controlSize` is what produced the original bug: sizes\n * `xs` and `sm` both took the `xs` button, which is a pill by design, and a\n * lozenge inside a 16px-radius field reads as a mistake.\n */\nconst TRIGGER = {\n xs: { size: 'sm', className: 'h-6 px-2.5 rounded-full [corner-shape:round]' },\n sm: { size: 'sm', className: 'h-7 px-3 rounded-[14px]' },\n md: { size: 'sm', className: '' },\n lg: { size: 'default', className: '' },\n xl: { size: 'default', className: 'h-11 px-5 rounded-[22px]' },\n} as const\n\nfunction InputFile({\n size = 'md',\n variant = 'default',\n error = false,\n placeholder = 'No file selected',\n buttonLabel = 'Browse',\n clearLabel = 'Clear',\n showList = true,\n multiple = false,\n disabled = false,\n maxSize,\n maxSizeLabel,\n accept,\n isUploading,\n uploadingLabel = (name) => `Uploading ${name}…`,\n className,\n onUpload,\n onSelect,\n onUploadsChange,\n ...props\n}: InputFileProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const id = useId()\n\n const { uploads, select, remove, retry, clear, uploading, failed, done } = useUploads({\n onUpload,\n multiple,\n maxSize,\n maxSizeLabel,\n accept,\n onSelect,\n onUploadsChange,\n })\n\n const busy = isUploading ?? uploading\n const inFlight = uploads.find((upload) => upload.status === 'uploading')\n\n let summary: ReactNode = placeholder\n if (busy) {\n summary = uploadingLabel(\n inFlight ? inFlight.name : `${uploads.length} file${uploads.length === 1 ? '' : 's'}`,\n )\n } else if (uploads.length === 1) summary = uploads[0].name\n else if (uploads.length > 1) summary = `${uploads.length} files`\n\n /** `UploadList` is presentational and takes `Attachment`, so map into it. */\n const attachments: Attachment[] = uploads.map((upload) => ({\n id: upload.id,\n name: upload.name,\n type: upload.type,\n size: upload.size,\n progress: upload.status === 'done' ? undefined : upload.progress,\n error: upload.error,\n }))\n\n return (\n <div className=\"flex w-full flex-col gap-2\">\n <div\n data-slot=\"input-file\"\n data-busy={busy}\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT[variant],\n (error || failed) && 'border-destructive',\n // The trailing inset belongs to text, not to a button. Every size\n // leaves 2px above and below the trigger, so the trailing side\n // matches it — the field's own text inset left a gap three times\n // that, and the button read as floating off the edge.\n 'pe-0.5',\n // The wrapper is the focus surface: focus lands on the hidden input,\n // so the ring is drawn from `focus-within` on the box around it.\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n {busy ? (\n <Spinner size=\"sm\" label=\"Uploading\" className=\"shrink-0\" />\n ) : done > 0 && !failed ? (\n <Check className=\"shrink-0 text-[var(--green-soft-foreground)]\" aria-hidden=\"true\" />\n ) : (\n <Paperclip className=\"text-muted-foreground shrink-0\" aria-hidden=\"true\" />\n )}\n\n <span\n id={id}\n className={cn(\n 'min-w-0 flex-1 truncate text-start',\n uploads.length === 0 && !busy && 'text-muted-foreground/70',\n )}\n >\n {summary}\n </span>\n\n {uploads.length > 0 && !busy && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size={TRIGGER[size].size}\n className={cn('shrink-0', TRIGGER[size].className)}\n onClick={() => {\n if (inputRef.current) inputRef.current.value = ''\n clear()\n }}\n >\n {clearLabel}\n </Button>\n )}\n\n <Button\n type=\"button\"\n variant=\"secondary\"\n size={TRIGGER[size].size}\n disabled={busy}\n className={cn('shrink-0', TRIGGER[size].className)}\n onClick={() => inputRef.current?.click()}\n >\n <Upload />\n {buttonLabel}\n </Button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled || busy}\n aria-labelledby={id}\n className={cn('sr-only', focusRing, disabledState, radius.control)}\n onChange={(event) => {\n select([...(event.target.files ?? [])])\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n {...props}\n />\n </div>\n\n {showList && uploads.length > 0 && (\n <UploadList uploads={attachments} onRemove={remove} onRetry={retry} />\n )}\n </div>\n )\n}\n\nexport { InputFile }\nexport type { InputFileProps }\nexport type { FileUpload, UploadControl, UploadHandler } from '@/lib/use-uploads'\n"
|
|
24
24
|
}
|
|
25
25
|
]
|
|
26
26
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lib-sql-select",
|
|
3
|
+
"type": "registry:lib",
|
|
4
|
+
"title": "sql-select",
|
|
5
|
+
"dependencies": [],
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "lib/sql-select.ts",
|
|
10
|
+
"type": "registry:lib",
|
|
11
|
+
"content": "/**\n * The `SELECT` grammar a query builder can round-trip, across providers.\n *\n * Two functions over one shape: `compileSelect` turns a query into a\n * parameterised statement, and `parseSelect` turns a statement back into a\n * query. They are here rather than in the component because a builder that can\n * only go one way is a dead end — you can construct a query but not open one\n * you already have — and because the safety argument below is worth testing on\n * its own, without a DOM.\n *\n * **Values are parameters; identifiers and functions are allow-listed.** A\n * value never enters the SQL text: it is emitted as a placeholder and returned\n * separately, which is what makes the output safe to execute. Identifiers\n * cannot be parameterised by any driver, so the only defence is a list — every\n * table, column and function name is looked up in what the caller declared, and\n * anything absent is dropped with the reason reported. Nothing reaches the\n * statement unless it was already in your schema.\n *\n * **Providers differ in ways that matter.** Quoting is `\"x\"`, `` `x` `` or\n * `[x]`; placeholders are `$1`, `?` or `@p1`; SQL Server has no `LIMIT` and its\n * `OFFSET…FETCH` requires an `ORDER BY`; booleans are literals in some and `1`\n * and `0` in others; and each has operators the others do not — `ILIKE`, `~`,\n * `REGEXP`, `GLOB`. `dialect` selects all of that at once, so a query built\n * against one provider states plainly what it cannot do on another rather than\n * emitting SQL that will not run.\n *\n * The subset is deliberate: `SELECT`, `FROM`, inner and left joins on a single\n * equality, a `WHERE` tree with `AND`/`OR` and brackets, `GROUP BY`, one\n * `ORDER BY`, and a row limit. Sub-queries, CTEs, unions and window functions\n * are not expressible, and `parseSelect` says so rather than half-reading them.\n */\n\nexport type SqlDialect = 'postgres' | 'mysql' | 'sqlite' | 'mssql'\nexport type Family = 'string' | 'number' | 'date' | 'boolean'\n\nexport type SqlTable = {\n name: string\n columns: { name: string; type?: string }[]\n}\n\n/**\n * A function the caller permits, by name.\n *\n * This is the answer to \"how do I use a function\": you declare it and it joins\n * the vocabulary. It is never free text — a name that is not on this list\n * cannot reach the statement, for the same reason a table name cannot.\n */\nexport type SqlFunction = {\n name: string\n label?: string\n /** Column families it accepts. Omitted means any. */\n families?: Family[]\n /** Family of the result, which decides the operators offered. */\n returns?: Family\n /** Providers that have it. Omitted means all. */\n dialects?: SqlDialect[]\n}\n\nexport type SqlCondition = {\n id: string\n /** A qualified `table.column`, or `fn(table.column)`. */\n field: string\n operator: string\n value: string\n /** For `many` operators — `IN`, `NOT IN`. */\n values?: string[]\n}\n\nexport type SqlGroup = {\n id: string\n join: 'and' | 'or'\n conditions: (SqlCondition | SqlGroup)[]\n}\n\nexport type SqlJoin = {\n id: string\n type: 'inner' | 'left'\n table: string\n leftColumn: string\n rightColumn: string\n}\n\nexport type SqlSort = { column: string; direction: 'asc' | 'desc' }\n\nexport type SelectQuery = {\n from: string\n joins: SqlJoin[]\n /** Qualified columns. Empty means every column. */\n select: string[]\n where: SqlGroup\n groupBy: string[]\n orderBy: SqlSort[]\n limit?: number\n}\n\n/** Kept for callers that only care about the placeholder style. */\nexport type Placeholders = 'numbered' | 'question'\n\nconst isGroup = (node: SqlCondition | SqlGroup): node is SqlGroup => 'conditions' in node\n\nlet counter = 0\nconst nextId = (prefix: string) => `${prefix}${++counter}`\n\n/* ------------------------------------------------------------- operators */\n\nexport type OperatorSpec = {\n id: string\n label: string\n /** `none` takes no value, `one` a single value, `many` a list. */\n arity: 'none' | 'one' | 'many'\n /** The infix token, or the whole tail for a `none` operator. */\n sql: string\n /** Column families it applies to. Omitted means all. */\n families?: Family[]\n /** Wraps the bound value — `%x%` for contains. Never touches the SQL. */\n wrap?: (value: string) => string\n /** Strips that wrapping when reading a statement back. */\n unwrap?: (value: string) => string\n}\n\n/** Everything every provider has. */\nconst BASE: OperatorSpec[] = [\n { id: 'eq', label: '=', arity: 'one', sql: '=' },\n { id: 'neq', label: '≠', arity: 'one', sql: '<>' },\n { id: 'gt', label: '>', arity: 'one', sql: '>', families: ['number'] },\n { id: 'gte', label: '≥', arity: 'one', sql: '>=', families: ['number'] },\n { id: 'lt', label: '<', arity: 'one', sql: '<', families: ['number'] },\n { id: 'lte', label: '≤', arity: 'one', sql: '<=', families: ['number'] },\n { id: 'after', label: 'after', arity: 'one', sql: '>', families: ['date'] },\n { id: 'before', label: 'before', arity: 'one', sql: '<', families: ['date'] },\n {\n id: 'contains',\n label: 'contains',\n arity: 'one',\n sql: 'LIKE',\n families: ['string'],\n wrap: (value) => `%${value}%`,\n unwrap: (value) => value.replace(/^%|%$/g, ''),\n },\n {\n id: 'starts',\n label: 'starts with',\n arity: 'one',\n sql: 'LIKE',\n families: ['string'],\n wrap: (value) => `${value}%`,\n unwrap: (value) => value.replace(/%$/, ''),\n },\n // The reason this module grew a `many` arity at all.\n { id: 'in', label: 'in', arity: 'many', sql: 'IN' },\n { id: 'nin', label: 'not in', arity: 'many', sql: 'NOT IN' },\n { id: 'set', label: 'is not null', arity: 'none', sql: 'IS NOT NULL' },\n { id: 'unset', label: 'is null', arity: 'none', sql: 'IS NULL' },\n]\n\ntype DialectSpec = {\n label: string\n quote: (name: string) => string\n placeholder: (position: number) => string\n /** How a row limit is written, given the placeholder for its value. */\n limit: (placeholder: string) => string\n /** Some providers can only limit an ordered result. */\n limitNeedsOrder?: boolean\n /** Added to `BASE`; an id already there replaces it. */\n operators: OperatorSpec[]\n}\n\nconst TRUE_FALSE: OperatorSpec[] = [\n { id: 'true', label: 'is true', arity: 'none', sql: '= TRUE', families: ['boolean'] },\n { id: 'false', label: 'is false', arity: 'none', sql: '= FALSE', families: ['boolean'] },\n]\n\n/** `1` and `0`, for providers with no boolean literal. */\nconst ONE_ZERO: OperatorSpec[] = [\n { id: 'true', label: 'is true', arity: 'none', sql: '= 1', families: ['boolean'] },\n { id: 'false', label: 'is false', arity: 'none', sql: '= 0', families: ['boolean'] },\n]\n\nexport const DIALECTS: Record<SqlDialect, DialectSpec> = {\n postgres: {\n label: 'PostgreSQL',\n quote: (name) => `\"${name}\"`,\n placeholder: (position) => `$${position}`,\n limit: (placeholder) => `LIMIT ${placeholder}`,\n operators: [\n ...TRUE_FALSE,\n {\n id: 'icontains',\n label: 'contains (any case)',\n arity: 'one',\n sql: 'ILIKE',\n families: ['string'],\n wrap: (value) => `%${value}%`,\n unwrap: (value) => value.replace(/^%|%$/g, ''),\n },\n { id: 'regex', label: 'matches regex', arity: 'one', sql: '~', families: ['string'] },\n { id: 'iregex', label: 'matches regex (any case)', arity: 'one', sql: '~*', families: ['string'] },\n ],\n },\n mysql: {\n label: 'MySQL',\n quote: (name) => `\\`${name}\\``,\n placeholder: () => '?',\n limit: (placeholder) => `LIMIT ${placeholder}`,\n operators: [\n ...TRUE_FALSE,\n { id: 'regex', label: 'matches regex', arity: 'one', sql: 'REGEXP', families: ['string'] },\n { id: 'sounds', label: 'sounds like', arity: 'one', sql: 'SOUNDS LIKE', families: ['string'] },\n ],\n },\n sqlite: {\n label: 'SQLite',\n quote: (name) => `\"${name}\"`,\n placeholder: () => '?',\n limit: (placeholder) => `LIMIT ${placeholder}`,\n operators: [\n ...ONE_ZERO,\n { id: 'glob', label: 'matches glob', arity: 'one', sql: 'GLOB', families: ['string'] },\n ],\n },\n mssql: {\n label: 'SQL Server',\n quote: (name) => `[${name}]`,\n placeholder: (position) => `@p${position}`,\n // No LIMIT. OFFSET…FETCH is the supported form, and it needs an ORDER BY.\n limit: (placeholder) => `OFFSET 0 ROWS FETCH NEXT ${placeholder} ROWS ONLY`,\n limitNeedsOrder: true,\n operators: [...ONE_ZERO],\n },\n}\n\n/** The operator list for a dialect, with its overrides applied. */\nexport function operatorsOf(dialect: SqlDialect): OperatorSpec[] {\n const extra = DIALECTS[dialect].operators\n const replaced = new Set(extra.map((item) => item.id))\n return [...BASE.filter((item) => !replaced.has(item.id)), ...extra]\n}\n\n/** Those of them that suit a column family. */\nexport function operatorsFor(dialect: SqlDialect, family: Family): OperatorSpec[] {\n return operatorsOf(dialect).filter(\n (operator) => !operator.families || operator.families.includes(family),\n )\n}\n\n/* --------------------------------------------------------------- schema */\n\n/** SQL types collapsed to the families the operator lists are built around. */\nexport function familyOf(type: string | undefined): Family {\n const lower = (type ?? '').toLowerCase()\n if (/bool/.test(lower)) return 'boolean'\n if (/int|numeric|decimal|real|double|float|money|serial/.test(lower)) return 'number'\n if (/date|time/.test(lower)) return 'date'\n return 'string'\n}\n\n/** A bare identifier — the only shape allowed into a statement. */\nconst IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/\n\nexport const qualify = (table: string, column: string) => `${table}.${column}`\n\nexport function splitQualified(key: string): [string, string] {\n const at = key.indexOf('.')\n return at === -1 ? ['', key] : [key.slice(0, at), key.slice(at + 1)]\n}\n\n/** `lower(users.email)` → `['lower', 'users.email']`; a plain key → `[null, key]`. */\nexport function splitFunction(key: string): [string | null, string] {\n const open = key.indexOf('(')\n if (open === -1 || !key.endsWith(')')) return [null, key]\n return [key.slice(0, open).trim(), key.slice(open + 1, -1).trim()]\n}\n\nexport const applyFunction = (fn: string, key: string) => `${fn}(${key})`\n\n/** The family a field resolves to, following a function's `returns`. */\nexport function familyOfField(\n key: string,\n tables: SqlTable[],\n functions: SqlFunction[] = [],\n): Family {\n const [fn, inner] = splitFunction(key)\n const [table, column] = splitQualified(inner)\n const type = tables.find((item) => item.name === table)?.columns.find((c) => c.name === column)?.type\n const base = familyOf(type)\n if (!fn) return base\n return functions.find((item) => item.name === fn)?.returns ?? base\n}\n\n/* --------------------------------------------------------------- compile */\n\nexport type Compiled = { sql: string; params: unknown[]; issues: string[] }\n\nexport type CompileOptions = {\n dialect?: SqlDialect\n /** Functions the caller permits. Anything else is dropped. */\n functions?: SqlFunction[]\n /** Overrides the dialect's placeholder style. */\n placeholders?: Placeholders\n}\n\nexport function compileSelect(\n query: SelectQuery,\n tables: SqlTable[],\n options: CompileOptions | Placeholders = {},\n): Compiled {\n // The third argument used to be the placeholder style; both still work.\n const opts: CompileOptions = typeof options === 'string' ? { placeholders: options } : options\n const dialect = opts.dialect ?? 'postgres'\n const spec = DIALECTS[dialect]\n const functions = (opts.functions ?? []).filter(\n (fn) => !fn.dialects || fn.dialects.includes(dialect),\n )\n\n const params: unknown[] = []\n const issues: string[] = []\n const placeholder = () =>\n opts.placeholders === 'question'\n ? '?'\n : opts.placeholders === 'numbered'\n ? `$${params.length}`\n : spec.placeholder(params.length)\n\n const known = new Set(tables.map((table) => table.name))\n const columnsOf = (name: string) =>\n new Set(tables.find((table) => table.name === name)?.columns.map((column) => column.name) ?? [])\n\n /**\n * The tables this statement can reference.\n *\n * Being in the schema is not enough: a column of a table that is neither the\n * source nor joined compiles to valid-looking SQL the database rejects, so\n * scope is checked separately from existence.\n */\n const inScope = new Set(\n [query.from, ...query.joins.map((join) => join.table)].filter((name) => known.has(name)),\n )\n\n /** A field — plain or wrapped in a declared function — as quoted SQL. */\n const resolve = (key: string): string | null => {\n const [fn, inner] = splitFunction(key)\n\n if (fn !== null) {\n const allowed = functions.find((item) => item.name === fn)\n if (!allowed) {\n issues.push(`${fn}() is not a declared function`)\n return null\n }\n if (!IDENTIFIER.test(fn)) {\n issues.push(`${fn} is not a plain identifier`)\n return null\n }\n const resolved = resolve(inner)\n return resolved === null ? null : `${fn}(${resolved})`\n }\n\n const [table, column] = splitQualified(key)\n if (!known.has(table) || !columnsOf(table).has(column)) {\n issues.push(`${key} is not in the schema`)\n return null\n }\n if (!inScope.has(table)) {\n issues.push(`${key} needs ${table} joined first`)\n return null\n }\n if (!IDENTIFIER.test(table) || !IDENTIFIER.test(column)) {\n issues.push(`${key} is not a plain identifier`)\n return null\n }\n return `${spec.quote(table)}.${spec.quote(column)}`\n }\n\n if (!known.has(query.from)) {\n return { sql: '', params: [], issues: ['Pick a table to select from.'] }\n }\n\n const operators = new Map(operatorsOf(dialect).map((operator) => [operator.id, operator]))\n\n const columns = query.select.map(resolve).filter(Boolean) as string[]\n const lines = [\n `SELECT ${columns.length ? columns.join(', ') : '*'}`,\n `FROM ${spec.quote(query.from)}`,\n ]\n\n for (const join of query.joins) {\n const left = resolve(join.leftColumn)\n const right = resolve(join.rightColumn)\n if (!known.has(join.table) || !left || !right) {\n issues.push('An incomplete join was left out.')\n continue\n }\n lines.push(\n `${join.type === 'left' ? 'LEFT JOIN' : 'INNER JOIN'} ${spec.quote(join.table)} ON ${left} = ${right}`,\n )\n }\n\n const condition = (node: SqlCondition | SqlGroup): string | null => {\n if (isGroup(node)) {\n const parts = node.conditions.map(condition).filter(Boolean) as string[]\n if (parts.length === 0) return null\n return parts.length === 1 ? parts[0] : `(${parts.join(` ${node.join.toUpperCase()} `)})`\n }\n\n const column = resolve(node.field)\n if (!column) return null\n\n const operator = operators.get(node.operator)\n if (!operator) {\n // Usually a query built for one provider opened against another.\n issues.push(`${node.operator} is not available on ${spec.label}`)\n return null\n }\n\n if (operator.arity === 'none') return `${column} ${operator.sql}`\n\n if (operator.arity === 'many') {\n const list = (node.values ?? []).map((item) => item.trim()).filter(Boolean)\n if (list.length === 0) {\n issues.push(`${node.field} has no values and was left out.`)\n return null\n }\n // One placeholder per item — the list is bound, never spliced.\n const slots = list.map((item) => {\n params.push(item)\n return placeholder()\n })\n return `${column} ${operator.sql} (${slots.join(', ')})`\n }\n\n if (node.value.trim() === '') {\n issues.push(`${node.field} has no value and was left out.`)\n return null\n }\n\n params.push(operator.wrap ? operator.wrap(node.value) : node.value)\n return `${column} ${operator.sql} ${placeholder()}`\n }\n\n const where = condition(query.where)\n // The outermost brackets are the statement's own; WHERE supplies them.\n if (where) lines.push(`WHERE ${where.startsWith('(') ? where.slice(1, -1) : where}`)\n\n const grouped = query.groupBy.map(resolve).filter(Boolean) as string[]\n if (grouped.length) lines.push(`GROUP BY ${grouped.join(', ')}`)\n\n const ordered = query.orderBy\n .map((sort) => {\n const column = resolve(sort.column)\n return column ? `${column} ${sort.direction.toUpperCase()}` : null\n })\n .filter(Boolean) as string[]\n if (ordered.length) lines.push(`ORDER BY ${ordered.join(', ')}`)\n\n if (query.limit !== undefined && Number.isFinite(query.limit)) {\n if (spec.limitNeedsOrder && ordered.length === 0) {\n // Not a stylistic point: OFFSET…FETCH without ORDER BY is a syntax error.\n issues.push(`${spec.label} can only limit an ordered result — add an ORDER BY.`)\n } else {\n // A limit is a number, but there is no reason for any caller-supplied\n // value to be concatenated.\n params.push(Math.max(0, Math.trunc(query.limit)))\n lines.push(spec.limit(placeholder()))\n }\n }\n\n return { sql: `${lines.join('\\n')};`, params, issues }\n}\n\n/* ------------------------------------------------------------------ parse */\n\ntype Token = {\n kind: 'word' | 'string' | 'number' | 'param' | 'op' | 'punct'\n value: string\n /** For quoted identifiers, so `\"select\"` is never read as a keyword. */\n quoted?: boolean\n}\n\nfunction tokenize(sql: string): Token[] {\n const out: Token[] = []\n let index = 0\n\n while (index < sql.length) {\n const char = sql[index]\n\n if (/\\s/.test(char)) {\n index++\n continue\n }\n // Comments, both flavours.\n if (char === '-' && sql[index + 1] === '-') {\n while (index < sql.length && sql[index] !== '\\n') index++\n continue\n }\n if (char === '/' && sql[index + 1] === '*') {\n index = sql.indexOf('*/', index + 2)\n index = index === -1 ? sql.length : index + 2\n continue\n }\n\n // Every provider's identifier quoting, so one parser reads them all.\n if (char === '\"' || char === '`' || char === '[') {\n const close = char === '\"' ? '\"' : char === '`' ? '`' : ']'\n const end = sql.indexOf(close, index + 1)\n if (end === -1) break\n out.push({ kind: 'word', value: sql.slice(index + 1, end), quoted: true })\n index = end + 1\n continue\n }\n\n if (char === \"'\") {\n let value = ''\n index++\n while (index < sql.length) {\n if (sql[index] === \"'\" && sql[index + 1] === \"'\") {\n value += \"'\"\n index += 2\n continue\n }\n if (sql[index] === \"'\") break\n value += sql[index++]\n }\n index++\n out.push({ kind: 'string', value })\n continue\n }\n\n // `$1`, `?`, `@p1` — every placeholder style.\n if (char === '$' && /\\d/.test(sql[index + 1] ?? '')) {\n let digits = ''\n index++\n while (/\\d/.test(sql[index] ?? '')) digits += sql[index++]\n out.push({ kind: 'param', value: digits })\n continue\n }\n if (char === '@' && /p?\\d/i.test(sql.slice(index + 1, index + 3))) {\n index++\n if (/p/i.test(sql[index] ?? '')) index++\n let digits = ''\n while (/\\d/.test(sql[index] ?? '')) digits += sql[index++]\n out.push({ kind: 'param', value: digits })\n continue\n }\n if (char === '?') {\n index++\n out.push({ kind: 'param', value: '' })\n continue\n }\n\n if (/\\d/.test(char)) {\n let digits = ''\n while (/[\\d.]/.test(sql[index] ?? '')) digits += sql[index++]\n out.push({ kind: 'number', value: digits })\n continue\n }\n\n if (/[A-Za-z_]/.test(char)) {\n let word = ''\n while (/[A-Za-z0-9_]/.test(sql[index] ?? '')) word += sql[index++]\n out.push({ kind: 'word', value: word })\n continue\n }\n\n const three = sql.slice(index, index + 2)\n if (three === '~*') {\n out.push({ kind: 'op', value: '~*' })\n index += 2\n continue\n }\n if (three === '<>' || three === '!=' || three === '>=' || three === '<=') {\n out.push({ kind: 'op', value: three === '!=' ? '<>' : three })\n index += 2\n continue\n }\n if (char === '=' || char === '<' || char === '>' || char === '~') {\n out.push({ kind: 'op', value: char })\n index++\n continue\n }\n\n out.push({ kind: 'punct', value: char })\n index++\n }\n\n return out\n}\n\nexport type Parsed = { query: SelectQuery | null; issues: string[] }\n\nexport type ParseOptions = CompileOptions & { params?: unknown[] }\n\n/**\n * Read a statement back into a query.\n *\n * `params` is optional: when the statement still carries placeholders — which\n * is what `compileSelect` produces — the values are taken from there, so a\n * compile/parse round trip is lossless. A statement written by hand with\n * literals parses without them.\n */\nexport function parseSelect(\n sql: string,\n tables: SqlTable[],\n options: ParseOptions | unknown[] = {},\n): Parsed {\n // The third argument used to be the parameter list; both still work.\n const opts: ParseOptions = Array.isArray(options) ? { params: options } : options\n const params = opts.params ?? []\n const dialect = opts.dialect ?? 'postgres'\n const functions = (opts.functions ?? []).filter(\n (fn) => !fn.dialects || fn.dialects.includes(dialect),\n )\n\n const issues: string[] = []\n const tokens = tokenize(sql)\n let at = 0\n let usedParams = 0\n\n const peek = (offset = 0) => tokens[at + offset]\n const isWord = (word: string, offset = 0) => {\n const token = peek(offset)\n return token?.kind === 'word' && !token.quoted && token.value.toLowerCase() === word\n }\n const eatWord = (word: string) => {\n if (!isWord(word)) return false\n at++\n return true\n }\n const isPunct = (value: string, offset = 0) =>\n peek(offset)?.kind === 'punct' && peek(offset)?.value === value\n\n const known = new Map(tables.map((table) => [table.name, table]))\n /** Tables the statement has brought into scope, filled as we go. */\n const scope = new Set<string>()\n\n /**\n * The name as written — `a.b`, a bare `b`, or `fn(a.b)`.\n *\n * Reading is separate from resolving because the select list is written\n * before the `FROM` that gives it meaning: nothing is in scope yet when those\n * tokens go past, so a bare column cannot be attributed and an unknown one\n * cannot be rejected. Everything after `FROM` resolves immediately; the\n * select list is held and resolved once the joins are in.\n */\n const rawColumn = (): string | null => {\n const first = peek()\n if (first?.kind !== 'word') return null\n\n // A declared function wrapping a column.\n if (!first.quoted && isPunct('(', 1)) {\n const name = first.value\n const known = functions.some((fn) => fn.name.toLowerCase() === name.toLowerCase())\n at += 2\n const inner = rawColumn()\n if (isPunct(')')) at++\n if (!inner) return null\n if (!known) {\n issues.push(`${name}() is not a declared function and was dropped`)\n return null\n }\n return applyFunction(name, inner)\n }\n\n at++\n if (isPunct('.') && peek(1)?.kind === 'word') {\n at++\n const second = peek() as Token\n at++\n return qualify(first.value, second.value)\n }\n return first.value\n }\n\n /** A field, only if the schema has it and its table is in scope. */\n const resolveKey = (raw: string | null): string | null => {\n if (!raw) return null\n\n const [fn, inner] = splitFunction(raw)\n if (fn !== null) {\n const resolved = resolveKey(inner)\n return resolved === null ? null : applyFunction(fn, resolved)\n }\n\n let key = raw\n if (!raw.includes('.')) {\n // Bare column: attribute it to the only table in scope that has one.\n const owners = [...scope].filter((name) =>\n known.get(name)?.columns.some((column) => column.name === raw),\n )\n if (owners.length > 1) {\n issues.push(`${raw} is ambiguous; qualify it`)\n return null\n }\n if (owners.length === 0) {\n issues.push(`${raw} is not in the schema and was dropped`)\n return null\n }\n key = qualify(owners[0], raw)\n }\n\n const [table, column] = splitQualified(key)\n if (!known.has(table) || !known.get(table)?.columns.some((item) => item.name === column)) {\n issues.push(`${key} is not in the schema and was dropped`)\n return null\n }\n if (!scope.has(table)) {\n issues.push(`${key} needs ${table} joined first`)\n return null\n }\n return key\n }\n\n const columnKey = () => resolveKey(rawColumn())\n\n const literal = (): string | null => {\n const token = peek()\n if (!token) return null\n if (token.kind === 'string' || token.kind === 'number') {\n at++\n return token.value\n }\n if (token.kind === 'param') {\n at++\n // Positional for `$n` and `@pn`, in order for `?`.\n const index = token.value ? Number(token.value) - 1 : usedParams++\n const value = params[index]\n return value === undefined ? '' : String(value)\n }\n if (token.kind === 'word' && !token.quoted) {\n at++\n return token.value\n }\n return null\n }\n\n /* ------------------------------------------------------------ SELECT */\n\n if (!eatWord('select')) return { query: null, issues: ['Only SELECT statements can be read.'] }\n if (eatWord('distinct')) issues.push('DISTINCT is not represented.')\n\n const rawSelect: string[] = []\n let selectAll = false\n if (isPunct('*')) {\n at++\n selectAll = true\n } else {\n do {\n const raw = rawColumn()\n if (raw) rawSelect.push(raw)\n else {\n issues.push('A selected expression was not a plain column and was dropped.')\n while (peek() && !isPunct(',') && !isWord('from')) at++\n }\n } while (isPunct(',') && ++at)\n }\n\n /* -------------------------------------------------------------- FROM */\n\n if (!eatWord('from')) return { query: null, issues: [...issues, 'No FROM clause.'] }\n const fromToken = peek()\n if (fromToken?.kind !== 'word') return { query: null, issues: [...issues, 'No table after FROM.'] }\n at++\n const from = fromToken.value\n if (!known.has(from)) {\n return { query: null, issues: [...issues, `${from} is not in the schema.`] }\n }\n scope.add(from)\n\n /* ------------------------------------------------------------- JOINs */\n\n const joins: SqlJoin[] = []\n for (;;) {\n let type: 'inner' | 'left' | null = null\n if (isWord('inner') && isWord('join', 1)) {\n at += 2\n type = 'inner'\n } else if (isWord('left') && (isWord('join', 1) || (isWord('outer', 1) && isWord('join', 2)))) {\n at += isWord('outer', 1) ? 3 : 2\n type = 'left'\n } else if (isWord('join')) {\n at++\n type = 'inner'\n }\n if (!type) break\n\n const table = peek()\n if (table?.kind !== 'word') {\n issues.push('A join had no table.')\n break\n }\n at++\n if (!known.has(table.value)) {\n issues.push(`${table.value} is not in the schema; the join was dropped.`)\n while (\n peek() &&\n !isWord('where') &&\n !isWord('join') &&\n !isWord('group') &&\n !isWord('order') &&\n !isWord('limit')\n ) {\n at++\n }\n continue\n }\n scope.add(table.value)\n\n if (!eatWord('on')) {\n issues.push('A join had no ON clause.')\n continue\n }\n const left = columnKey()\n if (peek()?.kind === 'op' && peek()?.value === '=') at++\n else issues.push('Only equality joins are represented.')\n const right = columnKey()\n\n if (left && right) {\n joins.push({ id: nextId('j'), type, table: table.value, leftColumn: left, rightColumn: right })\n }\n }\n\n // Scope is complete, so the held select list can finally be resolved.\n const select = rawSelect.map(resolveKey).filter(Boolean) as string[]\n\n /* ------------------------------------------------------------- WHERE */\n\n const operators = operatorsOf(dialect)\n /** SQL token back to an operator id, longest token first so `NOT IN` wins. */\n const byToken = [...operators].sort((a, b) => b.sql.length - a.sql.length)\n\n const predicate = (): SqlCondition | null => {\n const key = columnKey()\n if (!key) {\n // Skip to a boundary so one bad predicate does not lose the rest.\n while (peek() && !isWord('and') && !isWord('or') && !isPunct(')')) at++\n return null\n }\n\n const family = familyOfField(key, tables, functions)\n\n if (eatWord('is')) {\n const negated = eatWord('not')\n if (!eatWord('null')) issues.push('Only IS NULL and IS NOT NULL are represented.')\n return { id: nextId('c'), field: key, operator: negated ? 'set' : 'unset', value: '' }\n }\n\n // IN and NOT IN, with their bracketed list.\n const negatedIn = isWord('not') && isWord('in', 1)\n if (negatedIn || isWord('in')) {\n at += negatedIn ? 2 : 1\n const values: string[] = []\n if (isPunct('(')) {\n at++\n do {\n const item = literal()\n if (item !== null) values.push(item)\n } while (isPunct(',') && ++at)\n if (isPunct(')')) at++\n else issues.push('Unbalanced brackets in an IN list.')\n } else {\n issues.push('IN needs a bracketed list.')\n }\n return {\n id: nextId('c'),\n field: key,\n operator: negatedIn ? 'nin' : 'in',\n value: '',\n values,\n }\n }\n\n // A word operator — LIKE, ILIKE, REGEXP, GLOB, SOUNDS LIKE.\n const word = peek()\n if (word?.kind === 'word' && !word.quoted) {\n const text = word.value.toUpperCase()\n const twoWord = `${text} ${peek(1)?.kind === 'word' ? (peek(1) as Token).value.toUpperCase() : ''}`.trim()\n const match =\n byToken.find((operator) => operator.sql === twoWord) ??\n byToken.find((operator) => operator.sql === text)\n\n if (match) {\n at += match.sql.includes(' ') ? 2 : 1\n const raw = literal() ?? ''\n // `contains` and `starts` are both LIKE; the wildcards say which.\n const candidates = operators.filter(\n (operator) => operator.sql === match.sql && operator.arity === 'one',\n )\n const chosen =\n candidates.find(\n (operator) =>\n operator.id === 'contains' && raw.startsWith('%') && raw.endsWith('%'),\n ) ??\n candidates.find((operator) => operator.id === 'starts' && raw.endsWith('%')) ??\n candidates.find((operator) => operator.id === 'icontains' && raw.startsWith('%')) ??\n match\n return {\n id: nextId('c'),\n field: key,\n operator: chosen.id,\n value: chosen.unwrap ? chosen.unwrap(raw) : raw,\n }\n }\n }\n\n const symbol = peek()\n if (symbol?.kind !== 'op') {\n issues.push(`No operator after ${key}.`)\n return null\n }\n at++\n\n // `= TRUE` / `= 1` and their negatives, which are whole predicates.\n if (symbol.value === '=' && (isWord('true') || isWord('false'))) {\n const truthy = isWord('true')\n at++\n return { id: nextId('c'), field: key, operator: truthy ? 'true' : 'false', value: '' }\n }\n if (symbol.value === '=' && family === 'boolean' && peek()?.kind === 'number') {\n const digit = (peek() as Token).value\n at++\n return { id: nextId('c'), field: key, operator: digit === '0' ? 'false' : 'true', value: '' }\n }\n\n const raw = literal()\n if (raw === null) {\n issues.push(`No value after ${key}.`)\n return null\n }\n\n const candidates = operators.filter(\n (operator) => operator.sql === symbol.value && operator.arity === 'one',\n )\n // Dates read better as after/before, and those are what the builder offers.\n const chosen =\n candidates.find((operator) => operator.families?.includes(family)) ?? candidates[0]\n\n if (!chosen) {\n issues.push(`${symbol.value} is not available on ${DIALECTS[dialect].label}`)\n return null\n }\n return { id: nextId('c'), field: key, operator: chosen.id, value: raw }\n }\n\n const expression = (): SqlGroup => {\n const parseFactor = (): SqlCondition | SqlGroup | null => {\n if (isPunct('(')) {\n at++\n const inner = expression()\n if (isPunct(')')) at++\n else issues.push('Unbalanced brackets.')\n return inner.conditions.length === 1 ? inner.conditions[0] : inner\n }\n return predicate()\n }\n\n const first = parseFactor()\n const conditions: (SqlCondition | SqlGroup)[] = first ? [first] : []\n let join: 'and' | 'or' = 'and'\n\n while (isWord('and') || isWord('or')) {\n const next = isWord('or') ? 'or' : 'and'\n at++\n /*\n * Mixed AND and OR at one level are not the same query.\n *\n * `a AND b OR c` binds as `(a AND b) OR c`, and one join per group cannot\n * hold that flat. Rather than silently flatten it into something that\n * means something else, the tighter half is wrapped into its own group —\n * which is exactly what the brackets say.\n */\n if (conditions.length > 1 && next !== join) {\n if (next === 'and') {\n const last = conditions.pop() as SqlCondition | SqlGroup\n const operand = parseFactor()\n conditions.push({\n id: nextId('g'),\n join: 'and',\n conditions: operand ? [last, operand] : [last],\n })\n continue\n }\n const wrapped: SqlGroup = { id: nextId('g'), join, conditions: [...conditions] }\n conditions.length = 0\n conditions.push(wrapped)\n }\n join = next\n const operand = parseFactor()\n if (operand) conditions.push(operand)\n }\n\n return { id: nextId('g'), join, conditions }\n }\n\n let where: SqlGroup = { id: nextId('g'), join: 'and', conditions: [] }\n if (eatWord('where')) where = expression()\n\n /* ------------------------------------------- GROUP BY / ORDER BY / LIMIT */\n\n const groupBy: string[] = []\n if (isWord('group') && isWord('by', 1)) {\n at += 2\n do {\n const key = columnKey()\n if (key) groupBy.push(key)\n } while (isPunct(',') && ++at)\n }\n\n const orderBy: SqlSort[] = []\n if (isWord('order') && isWord('by', 1)) {\n at += 2\n do {\n const key = columnKey()\n const direction = eatWord('desc') ? 'desc' : (eatWord('asc'), 'asc')\n if (key) orderBy.push({ column: key, direction })\n } while (isPunct(',') && ++at)\n }\n\n let limit: number | undefined\n if (eatWord('limit')) {\n const parsed = Number(literal())\n if (Number.isFinite(parsed)) limit = parsed\n else issues.push('LIMIT was not a number.')\n } else if (eatWord('offset')) {\n // SQL Server's OFFSET n ROWS FETCH NEXT m ROWS ONLY.\n literal()\n eatWord('rows')\n if (eatWord('fetch')) {\n eatWord('next')\n eatWord('first')\n const parsed = Number(literal())\n if (Number.isFinite(parsed)) limit = parsed\n eatWord('rows')\n eatWord('only')\n }\n }\n\n for (const word of ['union', 'having', 'with']) {\n if (tokens.slice(at).some((token) => token.kind === 'word' && token.value.toLowerCase() === word)) {\n issues.push(`${word.toUpperCase()} is not represented and was dropped.`)\n }\n }\n\n return {\n query: { from, joins, select: selectAll ? [] : select, where, groupBy, orderBy, limit },\n issues,\n }\n}\n"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "query-constructor",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Query Constructor",
|
|
5
|
+
"description": "A SELECT assembled a clause at a time — sources with a join button, columns, filter, then order, group and limit — with the finished statement underneath. Reads both ways: paste a SELECT and the blocks fill in. Values compile to bound parameters and identifiers are allow-listed against the schema.",
|
|
6
|
+
"category": "Database",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"lucide-react@^1.39.0"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"button",
|
|
12
|
+
"code-block",
|
|
13
|
+
"command",
|
|
14
|
+
"input",
|
|
15
|
+
"lib-sql-select",
|
|
16
|
+
"lib-styles",
|
|
17
|
+
"lib-utils",
|
|
18
|
+
"multi-select",
|
|
19
|
+
"popover",
|
|
20
|
+
"schema-table",
|
|
21
|
+
"segment-builder",
|
|
22
|
+
"select",
|
|
23
|
+
"textarea"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
{
|
|
27
|
+
"path": "components/ui/query-constructor.tsx",
|
|
28
|
+
"type": "registry:ui",
|
|
29
|
+
"content": "import { useEffect, useId, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ClipboardPaste, Plus, X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { CommandList, type CommandItem } from '@/components/ui/command'\nimport { Input } from '@/components/ui/input'\nimport { MultiSelect } from '@/components/ui/multi-select'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { SegmentBuilder, type ConditionGroup } from '@/components/ui/segment-builder'\nimport { Select } from '@/components/ui/select'\nimport { Textarea } from '@/components/ui/textarea'\nimport type { SchemaColumn } from '@/components/ui/schema-table'\nimport {\n applyFunction,\n compileSelect,\n DIALECTS,\n familyOfField,\n operatorsFor,\n parseSelect,\n qualify,\n splitQualified,\n type SelectQuery,\n type SqlDialect,\n type SqlFunction,\n type SqlGroup,\n type SqlJoin,\n} from '@/lib/sql-select'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A SQL `SELECT` assembled a clause at a time, and readable back out of a\n * statement you paste.\n *\n * The counterpart to `QueryEditor`: that one is a console for people who\n * already know SQL; this is for people who know the *question* and not the\n * dialect. It reads both ways — paste a statement and the blocks fill in,\n * change a block and the statement follows — because a builder that only\n * compiles is a dead end: you can construct a query but never open the one you\n * already have.\n *\n * **One block per decision.** Sources, columns, filter, then the three small\n * ones that shape the result. Each is a titled card holding only its own\n * controls, so the query is read as a short list of choices rather than a long\n * form — and each block carries its own action, which is why joining a table is\n * a button on *Sources* rather than a control floating between clauses.\n *\n * **It follows the schema.** Every picker offers only columns of the tables in\n * play, qualified as `table.column` and annotated with their type; joining a\n * table brings its columns into the pool, and removing one takes its columns\n * out of the filter, the ordering and the grouping with it rather than leaving\n * a statement that names a table it no longer joins. A `references` on a column\n * is a join the schema already knows about, so it is offered as one click.\n *\n * **Values become parameters and identifiers are allow-listed** — see\n * `@/lib/sql-select`, which owns the grammar and is tested without a DOM. That\n * holds for pasted SQL too: a literal in a pasted `WHERE` becomes a bound\n * parameter when it compiles back, and a table you did not declare is dropped\n * rather than carried through.\n *\n * **The `WHERE` clause is a `SegmentBuilder`** — the same nested-precedence\n * problem, so the same component rather than two answers to what `A or B and C`\n * means.\n */\nexport type QueryTable = {\n name: string\n columns: SchemaColumn[]\n label?: ReactNode\n}\n\nexport type { SelectQuery as Query, SqlJoin as QueryJoin }\n\ntype QueryConstructorProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n tables: QueryTable[]\n value?: SelectQuery\n defaultValue?: SelectQuery\n onChange?: (query: SelectQuery) => void\n /** The compiled statement and its parameters, on every change. */\n onCompile?: (sql: string, params: unknown[]) => void\n /**\n * The provider the statement is for.\n *\n * It settles quoting, placeholders, how a row limit is written, whether\n * booleans are literals, and which operators exist — all at once, so a query\n * built for one provider says plainly what it cannot do on another instead of\n * emitting SQL that will not run.\n */\n dialect?: SqlDialect\n /** Starting provider when `dialect` is not controlled. */\n defaultDialect?: SqlDialect\n /** Providers offered in the switcher. Omit the switcher with a single one. */\n dialects?: SqlDialect[]\n onDialectChange?: (dialect: SqlDialect) => void\n /**\n * Functions the caller permits, by name.\n *\n * This is how a function gets used: declare it and it joins the vocabulary,\n * offered as `fn(table.column)` wherever a column can go. It is never free\n * text — an undeclared name cannot reach the statement, for the same reason\n * an unknown table cannot.\n */\n functions?: SqlFunction[]\n /** Hide the generated SQL. */\n preview?: boolean\n /** Hide the paste-a-statement panel. */\n importable?: boolean\n defaultLimit?: number\n label?: string\n runLabel?: ReactNode\n onRun?: (sql: string, params: unknown[]) => void\n}\n\n/**\n * One titled block.\n *\n * A `<section>` named by its own label rather than a heading: these are regions\n * of a control, not divisions of the document, and adding `h3`s inside a page\n * that already has its own outline would put headings in the document map that\n * are not part of it.\n */\nfunction Block({\n title,\n action,\n children,\n className,\n}: {\n title: string\n action?: ReactNode\n children: ReactNode\n className?: string\n}) {\n const id = useId()\n\n return (\n <section\n aria-labelledby={id}\n className={cn(surface, radius.surface, 'flex min-w-0 flex-col', className)}\n >\n <div className=\"border-border flex items-center justify-between gap-2 border-b px-3 py-1.5\">\n <p id={id} className=\"text-xs font-medium\">\n {title}\n </p>\n {action}\n </div>\n <div className=\"min-w-0 flex-1 p-3\">{children}</div>\n </section>\n )\n}\n\nlet counter = 0\nconst nextId = () => `j${++counter}`\n\nconst emptyQuery = (from: string): SelectQuery => ({\n from,\n joins: [],\n select: [],\n where: { id: 'where-root', join: 'and', conditions: [] },\n groupBy: [],\n orderBy: [],\n})\n\nfunction QueryConstructor({\n tables,\n value,\n defaultValue,\n onChange,\n onCompile,\n dialect: dialectProp,\n defaultDialect,\n dialects = ['postgres', 'mysql', 'sqlite', 'mssql'],\n onDialectChange,\n functions = [],\n preview = true,\n importable = true,\n defaultLimit,\n label = 'Query',\n runLabel,\n onRun,\n className,\n ...props\n}: QueryConstructorProps) {\n const [internal, setInternal] = useState<SelectQuery>(\n defaultValue ?? { ...emptyQuery(tables[0]?.name ?? ''), limit: defaultLimit },\n )\n const [importing, setImporting] = useState(false)\n const [draft, setDraft] = useState('')\n const [importIssues, setImportIssues] = useState<string[] | null>(null)\n const [joinOpen, setJoinOpen] = useState(false)\n const [internalDialect, setInternalDialect] = useState<SqlDialect>(\n dialectProp ?? defaultDialect ?? dialects[0] ?? 'postgres',\n )\n\n const dialect = dialectProp ?? internalDialect\n const setDialect = (next: SqlDialect) => {\n if (dialectProp === undefined) setInternalDialect(next)\n onDialectChange?.(next)\n }\n\n const query = value ?? internal\n\n const commit = (next: SelectQuery) => {\n if (value === undefined) setInternal(next)\n onChange?.(next)\n }\n\n /** The tables the query can reference: the source plus everything joined. */\n const scope = useMemo(\n () => new Set([query.from, ...query.joins.map((join) => join.table)].filter(Boolean)),\n [query.from, query.joins],\n )\n\n const inScope = useMemo(() => tables.filter((table) => scope.has(table.name)), [tables, scope])\n\n /** The functions available on this provider. */\n const available = useMemo(\n () => functions.filter((fn) => !fn.dialects || fn.dialects.includes(dialect)),\n [functions, dialect],\n )\n\n /**\n * Every field that can go where a column can: the columns of the tables in\n * play, and each declared function applied to the columns it accepts.\n */\n const columnOptions = useMemo(() => {\n const plain = inScope.flatMap((table) =>\n table.columns.map((column) => ({\n value: qualify(table.name, column.name),\n label: qualify(table.name, column.name),\n description: column.type,\n })),\n )\n const wrapped = available.flatMap((fn) =>\n plain\n .filter(\n (option) =>\n !fn.families || fn.families.includes(familyOfField(option.value, tables, available)),\n )\n .map((option) => ({\n value: applyFunction(fn.name, option.value),\n label: applyFunction(fn.name, option.value),\n description: fn.label ?? `${fn.name}()`,\n })),\n )\n return [...plain, ...wrapped]\n }, [inScope, available, tables])\n\n /**\n * The same fields for `SegmentBuilder`, each carrying the operators this\n * provider actually has — which is how `ILIKE` appears on PostgreSQL and\n * `REGEXP` on MySQL without the filter knowing anything about SQL.\n */\n const whereFields = useMemo(\n () =>\n columnOptions.map((option) => {\n const family = familyOfField(option.value, tables, available)\n return {\n key: option.value,\n label: option.label,\n type: family,\n operators: operatorsFor(dialect, family).map((operator) => ({\n value: operator.id,\n label: operator.label,\n arity: operator.arity,\n })),\n }\n }),\n [columnOptions, tables, available, dialect],\n )\n\n const compiled = useMemo(\n () => compileSelect(query, tables, { dialect, functions: available }),\n [query, tables, dialect, available],\n )\n\n /**\n * Reported when the statement changes, keyed on the output rather than the\n * callback's identity — an inline arrow at the call site is a new function\n * every render, and depending on it would fire this on every keystroke.\n */\n const latest = useRef(onCompile)\n latest.current = onCompile\n const compiledKey = `${compiled.sql}|${JSON.stringify(compiled.params)}`\n useEffect(() => {\n latest.current?.(compiled.sql, compiled.params)\n }, [compiledKey, compiled.sql, compiled.params])\n\n /* ------------------------------------------------------------ sources */\n\n /** Changing the source invalidates everything that named the old one. */\n const setFrom = (from: string) => commit({ ...emptyQuery(from), limit: query.limit })\n\n /** How a table could be joined, from a foreign key either way round. */\n const joinPath = (target: string) => {\n for (const table of inScope) {\n for (const column of table.columns) {\n if (!column.references) continue\n const [to, toColumn] = splitQualified(column.references)\n if (to === target) {\n return { leftColumn: qualify(table.name, column.name), rightColumn: qualify(to, toColumn) }\n }\n }\n }\n for (const column of tables.find((item) => item.name === target)?.columns ?? []) {\n if (!column.references) continue\n const [to, toColumn] = splitQualified(column.references)\n if (scope.has(to)) {\n return { leftColumn: qualify(to, toColumn), rightColumn: qualify(target, column.name) }\n }\n }\n return null\n }\n\n const addJoin = (target: string) => {\n const path = joinPath(target) ?? {\n leftColumn: qualify(\n query.from,\n tables.find((item) => item.name === query.from)?.columns[0]?.name ?? '',\n ),\n rightColumn: qualify(\n target,\n tables.find((item) => item.name === target)?.columns[0]?.name ?? '',\n ),\n }\n commit({\n ...query,\n joins: [...query.joins, { id: nextId(), type: 'inner', table: target, ...path }],\n })\n }\n\n /** Dropping a table takes everything that named it, or the statement breaks. */\n const dropJoin = (target: string) => {\n const keeps = (key: string) => splitQualified(key)[0] !== target\n const prune = (group: SqlGroup): SqlGroup => ({\n ...group,\n conditions: group.conditions\n .filter((node) => ('conditions' in node ? true : keeps(node.field)))\n .map((node) => ('conditions' in node ? prune(node) : node)),\n })\n commit({\n ...query,\n joins: query.joins.filter((join) => join.table !== target),\n select: query.select.filter(keeps),\n groupBy: query.groupBy.filter(keeps),\n orderBy: query.orderBy.filter((sort) => keeps(sort.column)),\n where: prune(query.where),\n })\n }\n\n const patchJoin = (id: string, changes: Partial<SqlJoin>) =>\n commit({\n ...query,\n joins: query.joins.map((join) => (join.id === id ? { ...join, ...changes } : join)),\n })\n\n const joinable = tables.filter((table) => !scope.has(table.name))\n const joinItems: CommandItem[] = joinable.map((table) => ({\n id: table.name,\n label: table.name,\n // A declared foreign key means this is one click, not a form.\n shortcut: joinPath(table.name) ? 'fk' : undefined,\n }))\n\n /* ------------------------------------------------------------- import */\n\n const importSql = () => {\n // The current parameters are passed so a statement copied straight out of\n // the preview — placeholders and all — round-trips with its values.\n const { query: parsed, issues } = parseSelect(draft, tables, {\n params: compiled.params,\n dialect,\n functions: available,\n })\n setImportIssues(issues)\n if (!parsed) return\n commit(parsed)\n // Closed only on a clean read: closing on a partial import hides the reason\n // half the query is missing.\n if (issues.length === 0) {\n setImporting(false)\n setDraft('')\n }\n }\n\n return (\n <div\n data-slot=\"query-constructor\"\n className={cn('@container flex flex-col gap-3', className)}\n {...props}\n >\n {/* ------------------------------------------------------ sources */}\n <Block\n title=\"Sources\"\n action={\n joinable.length > 0 && (\n <Popover open={joinOpen} onOpenChange={setJoinOpen}>\n <PopoverTrigger asChild>\n <Button size=\"xs\" variant=\"ghost\">\n <Plus />\n Join\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"end\" className=\"w-56 p-0\">\n <CommandList\n items={joinItems}\n placeholder=\"Table to join…\"\n emptyMessage=\"Nothing left to join\"\n onRun={(item) => {\n addJoin(item.id)\n setJoinOpen(false)\n }}\n />\n </PopoverContent>\n </Popover>\n )\n }\n >\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"text-muted-foreground w-10 shrink-0 font-mono text-[11px] uppercase\">\n from\n </span>\n <Select\n size=\"sm\"\n triggerLabel=\"Source table\"\n value={query.from}\n options={tables.map((table) => ({ value: table.name, label: table.name }))}\n onValueChange={setFrom}\n className=\"w-44\"\n triggerClassName=\"w-full\"\n />\n </div>\n\n {query.joins.map((join) => (\n <div key={join.id} className=\"flex flex-wrap items-center gap-2\">\n <Select\n size=\"sm\"\n triggerLabel=\"Join type\"\n value={join.type}\n options={[\n { value: 'inner', label: 'inner' },\n { value: 'left', label: 'left' },\n ]}\n onValueChange={(next) => patchJoin(join.id, { type: next as 'inner' | 'left' })}\n className=\"w-[4.5rem] shrink-0\"\n triggerClassName=\"w-full\"\n />\n <span className=\"w-16 shrink-0 truncate font-mono text-xs\">{join.table}</span>\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] uppercase\">\n on\n </span>\n <Select\n size=\"sm\"\n triggerLabel=\"Left column\"\n value={join.leftColumn}\n options={columnOptions.filter(\n (option) => splitQualified(option.value)[0] !== join.table,\n )}\n onValueChange={(next) => patchJoin(join.id, { leftColumn: next })}\n className=\"w-36\"\n triggerClassName=\"w-full\"\n />\n <span aria-hidden=\"true\" className=\"text-muted-foreground shrink-0\">\n =\n </span>\n <Select\n size=\"sm\"\n triggerLabel=\"Right column\"\n value={join.rightColumn}\n options={(tables.find((table) => table.name === join.table)?.columns ?? []).map(\n (column) => ({\n value: qualify(join.table, column.name),\n label: qualify(join.table, column.name),\n }),\n )}\n onValueChange={(next) => patchJoin(join.id, { rightColumn: next })}\n className=\"w-36\"\n triggerClassName=\"w-full\"\n />\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`Remove the ${join.table} join`}\n onClick={() => dropJoin(join.table)}\n >\n <X />\n </Button>\n </div>\n ))}\n </div>\n </Block>\n\n {/* ------------------------------------------------------ columns */}\n <Block\n title=\"Columns\"\n action={\n <span className=\"text-muted-foreground text-[11px] tabular-nums\">\n {query.select.length === 0 ? 'all' : `${query.select.length} of ${columnOptions.length}`}\n </span>\n }\n >\n <MultiSelect\n size=\"sm\"\n options={columnOptions}\n value={query.select}\n onValueChange={(next) => commit({ ...query, select: next })}\n placeholder=\"* — every column\"\n searchLabel=\"Filter columns\"\n triggerClassName=\"w-full\"\n />\n </Block>\n\n {/* ------------------------------------------------------- filter */}\n <Block title=\"Filter\">\n <SegmentBuilder\n fields={whereFields}\n value={query.where as ConditionGroup}\n onChange={(where) => commit({ ...query, where })}\n summary={false}\n label=\"\"\n emptyLabel=\"No filter — every row.\"\n />\n </Block>\n\n {/* ------------------------------------ order / group / limit */}\n <div className=\"grid gap-3 @[42rem]:grid-cols-3\">\n <Block\n title=\"Order\"\n action={\n query.orderBy[0] && (\n <Button\n size=\"icon-xs\"\n variant=\"ghost\"\n aria-label=\"Remove ordering\"\n onClick={() => commit({ ...query, orderBy: [] })}\n >\n <X />\n </Button>\n )\n }\n >\n <div className=\"flex flex-wrap items-center gap-2\">\n <Select\n size=\"sm\"\n triggerLabel=\"Order by\"\n placeholder=\"none\"\n value={query.orderBy[0]?.column ?? ''}\n options={columnOptions}\n onValueChange={(next) =>\n commit({\n ...query,\n orderBy: [{ column: next, direction: query.orderBy[0]?.direction ?? 'asc' }],\n })\n }\n className=\"min-w-0 flex-1\"\n triggerClassName=\"w-full\"\n />\n <Select\n size=\"sm\"\n triggerLabel=\"Direction\"\n value={query.orderBy[0]?.direction ?? 'asc'}\n options={[\n { value: 'asc', label: 'asc' },\n { value: 'desc', label: 'desc' },\n ]}\n disabled={!query.orderBy[0]}\n onValueChange={(next) =>\n commit({\n ...query,\n orderBy: query.orderBy[0]\n ? [{ ...query.orderBy[0], direction: next as 'asc' | 'desc' }]\n : [],\n })\n }\n className=\"w-20 shrink-0\"\n triggerClassName=\"w-full\"\n />\n </div>\n </Block>\n\n <Block\n title=\"Group\"\n action={\n query.groupBy.length > 0 && (\n <Button\n size=\"icon-xs\"\n variant=\"ghost\"\n aria-label=\"Remove grouping\"\n onClick={() => commit({ ...query, groupBy: [] })}\n >\n <X />\n </Button>\n )\n }\n >\n <MultiSelect\n size=\"sm\"\n options={columnOptions}\n value={query.groupBy}\n onValueChange={(next) => commit({ ...query, groupBy: next })}\n placeholder=\"none\"\n searchLabel=\"Filter columns\"\n triggerClassName=\"w-full\"\n />\n </Block>\n\n <Block title=\"Limit\">\n <Input\n size=\"sm\"\n type=\"number\"\n min={0}\n aria-label=\"Limit\"\n placeholder=\"none\"\n value={query.limit ?? ''}\n onChange={(event) =>\n commit({\n ...query,\n limit: event.target.value === '' ? undefined : Number(event.target.value),\n })\n }\n containerClassName=\"w-full\"\n />\n </Block>\n </div>\n\n {/* ------------------------------------------------- the statement */}\n {(preview || importable) && (\n <section className=\"flex flex-col gap-2\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <p className=\"text-sm font-medium\">{label}</p>\n <div className=\"flex flex-wrap items-center gap-1\">\n {/* The provider sits with the statement, because it is the\n statement it changes — the same query renders differently on\n each, and the operators on offer change with it. */}\n {dialects.length > 1 && (\n <Select\n size=\"sm\"\n triggerLabel=\"Provider\"\n value={dialect}\n options={dialects.map((item) => ({ value: item, label: DIALECTS[item].label }))}\n onValueChange={(next) => setDialect(next as SqlDialect)}\n className=\"w-36\"\n triggerClassName=\"w-full\"\n />\n )}\n {importable && (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n aria-expanded={importing}\n onClick={() => {\n const next = !importing\n setImporting(next)\n setImportIssues(null)\n // Seeded with the current statement, so the panel doubles as\n // \"edit this as text\" rather than only \"paste something else\".\n if (next) setDraft(compiled.sql)\n }}\n >\n <ClipboardPaste />\n Paste SQL\n </Button>\n )}\n {onRun && (\n <Button size=\"sm\" onClick={() => onRun(compiled.sql, compiled.params)}>\n {runLabel ?? 'Run'}\n </Button>\n )}\n </div>\n </div>\n\n {importable && importing && (\n <div className={cn(surface, radius.surface, 'flex flex-col gap-2 p-3')}>\n <label htmlFor=\"query-constructor-import\" className=\"text-xs font-medium\">\n Paste a SELECT and fill the blocks\n </label>\n <Textarea\n id=\"query-constructor-import\"\n rows={5}\n value={draft}\n spellCheck={false}\n onChange={(event) => setDraft(event.target.value)}\n placeholder=\"select email from users where country = 'DE' limit 50\"\n className=\"resize-y font-mono text-xs leading-relaxed\"\n />\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button size=\"sm\" onClick={importSql} disabled={!draft.trim()}>\n Fill the builder\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => {\n setImporting(false)\n setImportIssues(null)\n }}\n >\n Cancel\n </Button>\n {importIssues?.length === 0 && (\n <span role=\"status\" className=\"text-muted-foreground text-xs\">\n Read cleanly.\n </span>\n )}\n </div>\n\n {/* What was dropped, and why. A partial import that says nothing\n is worse than a refusal. */}\n {importIssues && importIssues.length > 0 && (\n <ul role=\"status\" className=\"text-muted-foreground list-none space-y-0.5 text-[11px]\">\n {importIssues.map((issue, index) => (\n <li key={index}>{issue}</li>\n ))}\n </ul>\n )}\n </div>\n )}\n\n {preview && (\n <>\n <CodeBlock code={compiled.sql || '-- pick a table'} language=\"sql\" />\n\n {compiled.params.length > 0 && (\n <p className=\"text-muted-foreground font-mono text-[11px]\">\n {/* Shown separately because that is what makes it safe: the\n values are bound at execution, never spliced into the text. */}\n params: [{compiled.params.map((param) => JSON.stringify(param)).join(', ')}]\n </p>\n )}\n\n {compiled.issues.length > 0 && (\n <ul role=\"status\" className=\"text-muted-foreground list-none space-y-0.5 text-[11px]\">\n {compiled.issues.map((issue, index) => (\n <li key={index}>{issue}</li>\n ))}\n </ul>\n )}\n </>\n )}\n </section>\n )}\n </div>\n )\n}\n\nexport { QueryConstructor }\nexport type { QueryConstructorProps }\n"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "scheduler",
|
|
3
3
|
"type": "registry:ui",
|
|
4
4
|
"title": "Scheduler",
|
|
5
|
-
"description": "A week of days with events laid out against the hours
|
|
5
|
+
"description": "A week of days with events laid out against the hours and edited in place — drag to move across days, drag an edge to change the end, drag empty grid to create. Overlaps split into columns rather than stacking.",
|
|
6
6
|
"category": "Views",
|
|
7
7
|
"dependencies": [],
|
|
8
8
|
"registryDependencies": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"path": "components/ui/scheduler.tsx",
|
|
15
15
|
"type": "registry:ui",
|
|
16
|
-
"content": "import { useId, useMemo, type ComponentProps, type ReactNode } from 'react'\nimport { dataPalette } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A week of days with events laid out against the hours.\n *\n * **`Calendar` picks a date; this shows what is in it.** They are different\n * components with different jobs, and conflating them produces a date picker\n * with a broken agenda glued on.\n *\n * **Overlapping events are laid out in columns, not stacked.** Two meetings at\n * the same time drawn on top of each other means one is invisible, which is the\n * single worst thing a calendar can do — the hidden one is the one you miss.\n * Overlaps are grouped into clusters and each cluster is split into as many\n * columns as it needs, so every event keeps a visible edge.\n *\n * **Times are rendered in the caller's timezone via `Intl`, and days are keyed\n * by local date.** A calendar that keys by UTC date puts a 23:00 event on the\n * wrong day for anyone west of Greenwich, which is a very common bug and hard\n * to spot from the timezone it was written in.\n *\n * `dayStart`/`dayEnd` crop the grid to working hours, because eight of the\n * twenty-four rows carry all the information in most calendars.\n */\nexport type SchedulerEvent = {\n id: string\n title: ReactNode\n start: Date | string\n end: Date | string\n color?: string\n /** Drawn as a full-width band above the grid. */\n allDay?: boolean\n}\n\ntype SchedulerProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n events: SchedulerEvent[]\n /** Any date inside the week to show. */\n week?: Date\n /** 0 is Sunday, 1 Monday. */\n weekStartsOn?: number\n days?: number\n /** First and last hour drawn. */\n dayStart?: number\n dayEnd?: number\n hourHeight?: number\n onSelect?: (event: SchedulerEvent) => void\n onSelectSlot?: (start: Date) => void\n locale?: string\n emptyLabel?: string\n label?: string\n}\n\nconst asDate = (value: Date | string) => (value instanceof Date ? value : new Date(value))\n/** Local date key — never UTC, or evening events land on the wrong day. */\nconst dayKey = (date: Date) =>\n `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`\n\nfunction Scheduler({\n events,\n week,\n weekStartsOn = 1,\n days = 7,\n dayStart = 8,\n dayEnd = 20,\n hourHeight = 44,\n onSelect,\n onSelectSlot,\n locale,\n emptyLabel = 'Nothing this week.',\n label = 'Schedule',\n className,\n ...props\n}: SchedulerProps) {\n const titleId = useId()\n\n const model = useMemo(() => {\n const anchor = week ? new Date(week) : new Date()\n anchor.setHours(0, 0, 0, 0)\n const offset = (anchor.getDay() - weekStartsOn + 7) % 7\n anchor.setDate(anchor.getDate() - offset)\n\n const columns = Array.from({ length: days }, (_, index) => {\n const date = new Date(anchor)\n date.setDate(anchor.getDate() + index)\n return date\n })\n\n const parsed = events.map((event) => ({\n event,\n start: asDate(event.start),\n end: asDate(event.end),\n }))\n\n /**\n * Split a day's events into overlap clusters, then give each cluster as\n * many columns as its widest overlap — so nothing is ever hidden behind\n * something else.\n */\n const layoutDay = (date: Date) => {\n const key = dayKey(date)\n const timed = parsed\n .filter((item) => !item.event.allDay && dayKey(item.start) === key)\n .sort((a, b) => a.start.getTime() - b.start.getTime())\n\n const placed: {\n event: SchedulerEvent\n top: number\n height: number\n column: number\n columns: number\n }[] = []\n\n let cluster: typeof timed = []\n let clusterEnd = 0\n\n const flush = () => {\n if (cluster.length === 0) return\n // Greedy column assignment inside the cluster.\n const lanes: number[] = []\n const assigned = cluster.map((item) => {\n let lane = lanes.findIndex((free) => free <= item.start.getTime())\n if (lane === -1) {\n lane = lanes.length\n lanes.push(0)\n }\n lanes[lane] = item.end.getTime()\n return { item, lane }\n })\n\n for (const { item, lane } of assigned) {\n const startHour = item.start.getHours() + item.start.getMinutes() / 60\n const endHour = item.end.getHours() + item.end.getMinutes() / 60\n placed.push({\n event: item.event,\n top: ((startHour - dayStart) / (dayEnd - dayStart)) * 100,\n height: Math.max(1.5, ((endHour - startHour) / (dayEnd - dayStart)) * 100),\n column: lane,\n columns: lanes.length,\n })\n }\n cluster = []\n }\n\n for (const item of timed) {\n if (cluster.length > 0 && item.start.getTime() >= clusterEnd) flush()\n cluster.push(item)\n clusterEnd = Math.max(clusterEnd, item.end.getTime())\n }\n flush()\n\n const allDay = parsed.filter(\n (item) => item.event.allDay && dayKey(item.start) === key,\n )\n\n return { placed, allDay }\n }\n\n return { columns, layoutDay, count: parsed.length }\n }, [events, week, weekStartsOn, days, dayStart, dayEnd])\n\n const { columns, layoutDay, count } = model\n const hours = Array.from({ length: dayEnd - dayStart }, (_, index) => dayStart + index)\n\n return (\n <div\n data-slot=\"scheduler\"\n className={cn('w-full overflow-x-auto', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n\n {count === 0 && <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>}\n\n <div className=\"grid min-w-[40rem]\" style={{ gridTemplateColumns: `3.5rem repeat(${days}, 1fr)` }}>\n <span />\n {columns.map((date) => (\n <div key={date.toISOString()} className=\"border-border border-b px-1 pb-1 text-center\">\n <p className=\"text-muted-foreground text-[11px]\">\n {date.toLocaleDateString(locale, { weekday: 'short' })}\n </p>\n <p className=\"text-sm font-medium tabular-nums\">{date.getDate()}</p>\n </div>\n ))}\n\n <span />\n {columns.map((date) => {\n const { allDay } = layoutDay(date)\n return (\n <div key={`allday-${date.toISOString()}`} className=\"border-border min-h-6 border-b p-0.5\">\n {allDay.map(({ event }) => (\n <button\n key={event.id}\n type=\"button\"\n disabled={!onSelect}\n onClick={() => onSelect?.(event)}\n className=\"mb-0.5 block w-full truncate rounded-[3px] px-1 py-0.5 text-start text-[10px] text-white\"\n style={{ background: event.color ?? dataPalette[0].fill }}\n >\n {event.title}\n </button>\n ))}\n </div>\n )\n })}\n\n <div className=\"border-border border-e\">\n {hours.map((hour) => (\n <div\n key={hour}\n className=\"text-muted-foreground pe-1 text-end text-[10px] tabular-nums\"\n style={{ height: hourHeight }}\n >\n {String(hour).padStart(2, '0')}:00\n </div>\n ))}\n </div>\n\n {columns.map((date) => {\n const { placed } = layoutDay(date)\n return (\n <div\n key={`grid-${date.toISOString()}`}\n className=\"border-border relative border-e\"\n style={{ height: hours.length * hourHeight }}\n >\n {hours.map((hour) => (\n <button\n key={hour}\n type=\"button\"\n disabled={!onSelectSlot}\n aria-label={`${date.toLocaleDateString(locale)} ${hour}:00`}\n onClick={() => {\n const slot = new Date(date)\n slot.setHours(hour, 0, 0, 0)\n onSelectSlot?.(slot)\n }}\n className=\"border-border/60 block w-full border-b\"\n style={{ height: hourHeight }}\n />\n ))}\n\n {placed.map((item) => (\n <button\n key={item.event.id}\n type=\"button\"\n disabled={!onSelect}\n onClick={() => onSelect?.(item.event)}\n title={`${asDate(item.event.start).toLocaleTimeString(locale, {\n hour: '2-digit',\n minute: '2-digit',\n })} – ${asDate(item.event.end).toLocaleTimeString(locale, {\n hour: '2-digit',\n minute: '2-digit',\n })}`}\n className=\"absolute overflow-hidden rounded-[3px] px-1 py-0.5 text-start text-[10px] leading-tight text-white\"\n style={{\n top: `${item.top}%`,\n height: `${item.height}%`,\n // Columns, so a clash never hides an event entirely.\n insetInlineStart: `${(item.column / item.columns) * 100}%`,\n width: `calc(${100 / item.columns}% - 2px)`,\n background: item.event.color ?? dataPalette[1].fill,\n }}\n >\n {item.event.title}\n </button>\n ))}\n </div>\n )\n })}\n </div>\n </div>\n )\n}\n\nexport { Scheduler }\nexport type { SchedulerProps }\n"
|
|
16
|
+
"content": "import {\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n} from 'react'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A week of days with events laid out against the hours, and edited in place.\n *\n * **`Calendar` picks a date; this shows what is in it.** They are different\n * components with different jobs, and conflating them produces a date picker\n * with a broken agenda glued on.\n *\n * **A calendar you cannot drag on is a printout.** Moving a meeting, making it\n * longer, and blocking out an hour are the three things people do with a week\n * view, so all three are direct: drag an event to move it — across days as well\n * as hours — drag its bottom edge to change the end, and drag on empty grid to\n * create.\n *\n * **Everything snaps to `snapMinutes`.** Fifteen is the unit calendars are\n * actually kept in; without a grid a drag produces 10:07–11:04 and nobody wants\n * that meeting.\n *\n * **Events are cards, in the same surface the rest of the kit uses.** A week\n * view is a reading surface, not a heat map: a card with a border, a title and\n * a time is scannable, and it sits in a page beside other cards without looking\n * like a different product. A solid rail down the leading edge is what marks it\n * as an event, and it is the one element that stays at full strength when the\n * card is dimmed.\n *\n * **It is monochrome.** A week is read for shape — where the gaps are, what\n * collides — and colouring events by an arbitrary category adds a legend\n * without adding information. Overlapping blocks are separated by a hairline in\n * the page's own background rather than by hue, which keeps them distinct at\n * any theme and in print.\n *\n * **Overlapping events are laid out in columns, not stacked.** Two meetings at\n * the same time drawn on top of each other means one is invisible, which is the\n * worst thing a calendar can do — the hidden one is the one you miss. Overlaps\n * are grouped into clusters and each cluster is split into as many columns as\n * it needs, so every event keeps a visible edge, and that stays true while you\n * are dragging one on top of another.\n *\n * **Times are rendered in the caller's timezone and days are keyed by local\n * date.** A calendar that keys by UTC puts a 23:00 event on the wrong day for\n * anyone west of Greenwich — a common bug, and invisible from the timezone it\n * was written in.\n *\n * Events are also editable from the keyboard: arrow keys move by a snap,\n * shift-arrow changes the end, and the current time is announced through\n * `aria-valuetext`.\n */\nexport type SchedulerEvent = {\n id: string\n title: ReactNode\n start: Date | string\n end: Date | string\n /** Drawn as a full-width band above the grid. */\n allDay?: boolean\n /** Excluded from dragging and keyboard editing. */\n locked?: boolean\n}\n\ntype SchedulerProps = Omit<ComponentProps<'div'>, 'onSelect' | 'onChange'> & {\n events: SchedulerEvent[]\n /** The whole list after an edit. Omit to make the grid read-only. */\n onChange?: (events: SchedulerEvent[]) => void\n /** Just the event that moved. */\n onEventChange?: (event: SchedulerEvent, previous: SchedulerEvent) => void\n /** A drag on empty grid. Return an id, or nothing to decline. */\n onCreate?: (start: Date, end: Date) => SchedulerEvent | void\n onSelect?: (event: SchedulerEvent) => void\n /** Any date inside the week to show. */\n week?: Date\n /** 0 is Sunday, 1 Monday. */\n weekStartsOn?: number\n days?: number\n /** First and last hour drawn. */\n dayStart?: number\n dayEnd?: number\n /**\n * Pixels per hour. Generous by default — a week view is a reading surface,\n * and an hour compressed to 40px cannot hold a title and a time.\n */\n hourHeight?: number\n /** Grid the drag snaps to. */\n snapMinutes?: number\n locale?: string\n emptyLabel?: string\n label?: string\n}\n\nconst asDate = (value: Date | string) => (value instanceof Date ? value : new Date(value))\n/** Local date key — never UTC, or evening events land on the wrong day. */\nconst dayKey = (date: Date) => `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`\nconst minutesOf = (date: Date) => date.getHours() * 60 + date.getMinutes()\n\ntype Mode = 'move' | 'resize' | 'create'\ntype Drag = {\n mode: Mode\n id?: string\n /** Minutes from the top of the event to the grab point, for `move`. */\n grabOffset: number\n duration: number\n /** Live result, mirrored into state for rendering. */\n dayIndex: number\n startMinutes: number\n endMinutes: number\n}\n\nfunction Scheduler({\n events,\n onChange,\n onEventChange,\n onCreate,\n onSelect,\n week,\n weekStartsOn = 1,\n days = 7,\n dayStart = 8,\n dayEnd = 20,\n hourHeight = 64,\n snapMinutes = 15,\n locale,\n emptyLabel = 'Nothing this week.',\n label = 'Schedule',\n className,\n ...props\n}: SchedulerProps) {\n const titleId = useId()\n const gridRef = useRef<HTMLDivElement>(null)\n const drag = useRef<Drag | null>(null)\n const [preview, setPreview] = useState<Drag | null>(null)\n\n const editable = Boolean(onChange || onEventChange)\n\n const columns = useMemo(() => {\n const anchor = week ? new Date(week) : new Date()\n anchor.setHours(0, 0, 0, 0)\n anchor.setDate(anchor.getDate() - ((anchor.getDay() - weekStartsOn + 7) % 7))\n return Array.from({ length: days }, (_, index) => {\n const date = new Date(anchor)\n date.setDate(anchor.getDate() + index)\n return date\n })\n }, [week, weekStartsOn, days])\n\n /** Events with the in-flight drag applied, so layout reflows as you move. */\n const resolved = useMemo(() => {\n return events.map((event) => {\n const start = asDate(event.start)\n const end = asDate(event.end)\n if (!preview || preview.id !== event.id) return { event, start, end }\n\n const day = columns[preview.dayIndex] ?? start\n const moved = new Date(day)\n moved.setHours(0, preview.startMinutes, 0, 0)\n const until = new Date(day)\n until.setHours(0, preview.endMinutes, 0, 0)\n return { event, start: moved, end: until }\n })\n }, [events, preview, columns])\n\n /** Cluster overlaps and give each cluster as many columns as it needs. */\n const layoutFor = (date: Date) => {\n const key = dayKey(date)\n const timed = resolved\n .filter((item) => !item.event.allDay && dayKey(item.start) === key)\n .sort((a, b) => a.start.getTime() - b.start.getTime())\n\n const placed: {\n event: SchedulerEvent\n start: Date\n end: Date\n top: number\n height: number\n column: number\n columns: number\n short: boolean\n }[] = []\n\n let cluster: typeof timed = []\n let clusterEnd = 0\n\n const flush = () => {\n if (cluster.length === 0) return\n const lanes: number[] = []\n const assigned = cluster.map((item) => {\n let lane = lanes.findIndex((free) => free <= item.start.getTime())\n if (lane === -1) {\n lane = lanes.length\n lanes.push(0)\n }\n lanes[lane] = item.end.getTime()\n return { item, lane }\n })\n\n const window = (dayEnd - dayStart) * 60\n for (const { item, lane } of assigned) {\n const from = minutesOf(item.start)\n const to = minutesOf(item.end)\n placed.push({\n event: item.event,\n start: item.start,\n end: item.end,\n top: ((from - dayStart * 60) / window) * 100,\n height: Math.max(1.5, ((to - from) / window) * 100),\n column: lane,\n columns: lanes.length,\n // A short event has no room for a second line, so it gets a single\n // one rather than two clipped halves. Stacking title over time in a\n // 26px card shows the top of each and neither in full.\n short: to - from < 45,\n })\n }\n cluster = []\n }\n\n for (const item of timed) {\n if (cluster.length > 0 && item.start.getTime() >= clusterEnd) flush()\n cluster.push(item)\n clusterEnd = Math.max(clusterEnd, item.end.getTime())\n }\n flush()\n\n return {\n placed,\n allDay: resolved.filter((item) => item.event.allDay && dayKey(item.start) === key),\n }\n }\n\n /* ------------------------------------------------------------ dragging */\n\n const latest = useRef({ events, columns, onChange, onEventChange, onCreate, snapMinutes, dayStart, dayEnd })\n latest.current = { events, columns, onChange, onEventChange, onCreate, snapMinutes, dayStart, dayEnd }\n\n const commit = (id: string, day: Date, startMinutes: number, endMinutes: number) => {\n const list = latest.current.events\n const previous = list.find((item) => item.id === id)\n if (!previous) return\n\n const start = new Date(day)\n start.setHours(0, startMinutes, 0, 0)\n const end = new Date(day)\n end.setHours(0, endMinutes, 0, 0)\n\n const next: SchedulerEvent = { ...previous, start, end }\n latest.current.onEventChange?.(next, previous)\n latest.current.onChange?.(list.map((item) => (item.id === id ? next : item)))\n }\n\n const beginDrag = (\n event: ReactPointerEvent,\n mode: Mode,\n payload: { id?: string; dayIndex: number; startMinutes: number; endMinutes: number },\n ) => {\n if (!editable && mode !== 'create') return\n if (mode === 'create' && !onCreate) return\n\n event.preventDefault()\n event.stopPropagation()\n\n const pointer = pointerToGrid(event.clientX, event.clientY)\n const state: Drag = {\n mode,\n id: payload.id,\n grabOffset: pointer ? pointer.minutes - payload.startMinutes : 0,\n duration: payload.endMinutes - payload.startMinutes,\n dayIndex: payload.dayIndex,\n startMinutes: payload.startMinutes,\n endMinutes: payload.endMinutes,\n }\n drag.current = state\n setPreview(state)\n }\n\n /** Pointer position as a day column and a snapped minute of that day. */\n const pointerToGrid = (clientX: number, clientY: number) => {\n const grid = gridRef.current\n if (!grid) return null\n const box = grid.getBoundingClientRect()\n const { snapMinutes: step, dayStart: from, dayEnd: to, columns: cols } = latest.current\n\n const width = box.width / cols.length\n const dayIndex = Math.min(cols.length - 1, Math.max(0, Math.floor((clientX - box.left) / width)))\n\n const window = (to - from) * 60\n const raw = from * 60 + ((clientY - box.top) / box.height) * window\n const minutes = Math.round(raw / step) * step\n\n return { dayIndex, minutes: Math.min(to * 60, Math.max(from * 60, minutes)) }\n }\n\n useEffect(() => {\n const onMove = (event: PointerEvent) => {\n const state = drag.current\n if (!state) return\n const point = pointerToGrid(event.clientX, event.clientY)\n if (!point) return\n\n const { snapMinutes: step, dayEnd: to } = latest.current\n\n if (state.mode === 'resize') {\n // The end cannot pass the start; one snap is the shortest event.\n const end = Math.max(state.startMinutes + step, Math.min(point.minutes, to * 60))\n const next = { ...state, endMinutes: end }\n drag.current = next\n setPreview(next)\n return\n }\n\n if (state.mode === 'create') {\n const from = Math.min(state.startMinutes, point.minutes)\n const end = Math.max(state.startMinutes + step, point.minutes)\n const next = { ...state, startMinutes: from, endMinutes: end, dayIndex: state.dayIndex }\n drag.current = next\n setPreview(next)\n return\n }\n\n // Move: the grab offset keeps the pointer on the same part of the event,\n // rather than snapping its top to the cursor.\n const start = Math.max(\n latest.current.dayStart * 60,\n Math.min(point.minutes - state.grabOffset, to * 60 - state.duration),\n )\n const next = {\n ...state,\n dayIndex: point.dayIndex,\n startMinutes: start,\n endMinutes: start + state.duration,\n }\n drag.current = next\n setPreview(next)\n }\n\n const onUp = () => {\n const state = drag.current\n drag.current = null\n setPreview(null)\n if (!state) return\n\n const day = latest.current.columns[state.dayIndex]\n if (!day) return\n\n if (state.mode === 'create') {\n const start = new Date(day)\n start.setHours(0, state.startMinutes, 0, 0)\n const end = new Date(day)\n end.setHours(0, state.endMinutes, 0, 0)\n const created = latest.current.onCreate?.(start, end)\n if (created) latest.current.onChange?.([...latest.current.events, created])\n return\n }\n\n if (state.id) commit(state.id, day, state.startMinutes, state.endMinutes)\n }\n\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', onUp)\n window.addEventListener('pointercancel', onUp)\n return () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', onUp)\n window.removeEventListener('pointercancel', onUp)\n }\n }, [])\n\n const hours = Array.from({ length: dayEnd - dayStart }, (_, index) => dayStart + index)\n const windowMinutes = (dayEnd - dayStart) * 60\n const timeOf = (date: Date) =>\n date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })\n\n return (\n <div\n data-slot=\"scheduler\"\n className={cn('w-full overflow-x-auto', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n\n {events.length === 0 && <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>}\n\n <div className=\"min-w-[40rem]\">\n <div className=\"grid\" style={{ gridTemplateColumns: `3.5rem repeat(${days}, 1fr)` }}>\n <span />\n {columns.map((date) => {\n const isToday = dayKey(date) === dayKey(new Date())\n return (\n <div\n key={date.toISOString()}\n className=\"border-border flex items-baseline justify-center gap-1.5 border-b px-2 pt-1 pb-2\"\n >\n <span className=\"text-muted-foreground text-xs uppercase\">\n {date.toLocaleDateString(locale, { weekday: 'short' })}\n </span>\n <span\n className={cn(\n 'text-base font-medium tabular-nums',\n // Today is weighted, not tinted — the whole grid is\n // monochrome, so emphasis has to come from weight.\n isToday &&\n 'bg-foreground text-background flex size-6 items-center justify-center rounded-full text-sm',\n )}\n >\n {date.getDate()}\n </span>\n </div>\n )\n })}\n\n <span />\n {columns.map((date) => (\n <div\n key={`allday-${date.toISOString()}`}\n className=\"border-border min-h-9 border-b border-e p-1\"\n >\n {layoutFor(date).allDay.map(({ event }) => (\n <button\n key={event.id}\n type=\"button\"\n onClick={() => onSelect?.(event)}\n className={cn(\n 'relative mb-1 block w-full truncate ps-2.5 pe-2 py-1 text-start text-xs',\n surface,\n radius.control,\n 'hover:bg-muted',\n focusRing,\n )}\n >\n <span\n aria-hidden=\"true\"\n className=\"bg-foreground absolute inset-y-1 start-1 w-[3px] rounded-full\"\n />\n {event.title}\n </button>\n ))}\n </div>\n ))}\n </div>\n\n <div className=\"grid\" style={{ gridTemplateColumns: `3.5rem repeat(${days}, 1fr)` }}>\n <div className=\"border-border border-e\">\n {hours.map((hour) => (\n <div key={hour} className=\"relative\" style={{ height: hourHeight }}>\n {/* Sat on the line it labels, not centred in the band below it. */}\n <span className=\"text-muted-foreground absolute end-2 -top-1.5 text-[11px] tabular-nums\">\n {String(hour).padStart(2, '0')}:00\n </span>\n </div>\n ))}\n </div>\n\n {/*\n One grid element spanning every day, measured once.\n\n The pointer has to be resolvable to a *day* as well as a time — a\n drag that starts on Tuesday and ends on Thursday is the ordinary\n case — so the geometry is taken from the whole grid rather than from\n whichever column the gesture began in.\n */}\n <div\n ref={gridRef}\n className=\"col-span-full grid\"\n style={{\n gridTemplateColumns: `repeat(${days}, 1fr)`,\n gridColumn: '2 / -1',\n height: hours.length * hourHeight,\n }}\n >\n {columns.map((date, dayIndex) => {\n const { placed } = layoutFor(date)\n\n return (\n <div\n key={`grid-${date.toISOString()}`}\n className=\"border-border relative border-e px-px\"\n onPointerDown={(event) => {\n // Empty grid: start drawing a new event.\n if (event.target !== event.currentTarget) return\n const point = pointerToGrid(event.clientX, event.clientY)\n if (!point) return\n beginDrag(event, 'create', {\n dayIndex,\n startMinutes: point.minutes,\n endMinutes: point.minutes + snapMinutes,\n })\n }}\n >\n {hours.map((hour) => (\n <div\n key={hour}\n aria-hidden=\"true\"\n className=\"border-border pointer-events-none relative border-b\"\n style={{ height: hourHeight }}\n >\n {/* The half hour, lighter — enough to aim a 30-minute\n drag at without ruling the grid twice. */}\n <span className=\"border-border/40 absolute inset-x-0 top-1/2 border-b border-dashed\" />\n </div>\n ))}\n\n {/* The event being drawn, before it exists. */}\n {preview?.mode === 'create' && preview.dayIndex === dayIndex && (\n <div\n aria-hidden=\"true\"\n className=\"border-foreground/40 bg-foreground/10 pointer-events-none absolute inset-x-1 rounded-[3px] border border-dashed\"\n style={{\n top: `${((preview.startMinutes - dayStart * 60) / windowMinutes) * 100}%`,\n height: `${((preview.endMinutes - preview.startMinutes) / windowMinutes) * 100}%`,\n }}\n />\n )}\n\n {placed.map((item) => {\n const locked = item.event.locked || !editable\n const dragging = preview?.id === item.event.id\n const readable = `${timeOf(item.start)} – ${timeOf(item.end)}`\n\n return (\n <div\n key={item.event.id}\n role={locked ? undefined : 'slider'}\n tabIndex={locked ? undefined : 0}\n aria-label={\n typeof item.event.title === 'string' ? item.event.title : 'Event'\n }\n aria-valuetext={`${readable}, ${item.start.toLocaleDateString(locale)}`}\n title={readable}\n onPointerDown={(pointerEvent) =>\n !locked &&\n beginDrag(pointerEvent, 'move', {\n id: item.event.id,\n dayIndex,\n startMinutes: minutesOf(item.start),\n endMinutes: minutesOf(item.end),\n })\n }\n onClick={() => onSelect?.(item.event)}\n onKeyDown={(keyEvent) => {\n if (locked) return\n const step =\n keyEvent.key === 'ArrowDown' ? snapMinutes\n : keyEvent.key === 'ArrowUp' ? -snapMinutes\n : 0\n const dayStep =\n keyEvent.key === 'ArrowRight' ? 1 : keyEvent.key === 'ArrowLeft' ? -1 : 0\n if (!step && !dayStep) return\n keyEvent.preventDefault()\n\n const from = minutesOf(item.start)\n const to = minutesOf(item.end)\n const targetDay =\n columns[Math.min(columns.length - 1, Math.max(0, dayIndex + dayStep))]\n\n if (keyEvent.shiftKey && step) {\n commit(item.event.id, targetDay, from, Math.max(from + snapMinutes, to + step))\n } else {\n commit(item.event.id, targetDay, from + step, to + step)\n }\n }}\n className={cn(\n 'group absolute flex overflow-hidden ps-2.5 pe-1.5 text-start',\n item.short ? 'items-center py-0' : 'flex-col py-1',\n surface,\n radius.control,\n 'shadow-sm',\n locked ? 'cursor-default' : 'cursor-grab hover:bg-muted',\n dragging && 'ring-foreground cursor-grabbing shadow-md ring-2',\n focusRing,\n )}\n style={{\n top: `${item.top}%`,\n height: `${item.height}%`,\n // Enough to hold a title and a time even for a\n // fifteen-minute event, which is the shortest one the\n // default snap can produce.\n minHeight: item.short ? 20 : 34,\n // Columns, so a clash never hides an event entirely.\n insetInlineStart: `calc(${(item.column / item.columns) * 100}% + 2px)`,\n width: `calc(${100 / item.columns}% - 6px)`,\n }}\n >\n {/* The rail is what reads as \"event\"; it stays at full\n strength when the card itself is quiet. */}\n <span\n aria-hidden=\"true\"\n className=\"bg-foreground absolute inset-y-1 start-1 w-[3px] rounded-full\"\n />\n {item.short ? (\n <span className=\"flex min-w-0 items-baseline gap-1.5\">\n <span className=\"truncate text-xs font-medium\">{item.event.title}</span>\n <span className=\"text-muted-foreground shrink-0 text-[11px] tabular-nums\">\n {timeOf(item.start)}\n </span>\n </span>\n ) : (\n <>\n <span className=\"truncate text-xs font-medium\">{item.event.title}</span>\n <span className=\"text-muted-foreground truncate text-[11px] tabular-nums\">\n {timeOf(item.start)} – {timeOf(item.end)}\n </span>\n </>\n )}\n\n {/* Bottom edge: change the end without moving the start. */}\n {!locked && (\n <span\n role=\"presentation\"\n onPointerDown={(pointerEvent) => {\n pointerEvent.stopPropagation()\n beginDrag(pointerEvent, 'resize', {\n id: item.event.id,\n dayIndex,\n startMinutes: minutesOf(item.start),\n endMinutes: minutesOf(item.end),\n })\n }}\n className={cn(\n 'absolute inset-x-0 bottom-0 h-2 cursor-ns-resize',\n // Invisible until you are near it, then a clear\n // grip — a permanent bar on every card is noise.\n 'after:bg-muted-foreground/50 after:absolute after:inset-x-1/3 after:bottom-0.5 after:h-0.5',\n 'after:rounded-full after:opacity-0 group-hover:after:opacity-100',\n )}\n />\n )}\n </div>\n )\n })}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n </div>\n )\n}\n\nexport { Scheduler }\nexport type { SchedulerProps }\n"
|
|
17
17
|
}
|
|
18
18
|
]
|
|
19
19
|
}
|
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
11
|
"button",
|
|
12
|
+
"input",
|
|
12
13
|
"lib-styles",
|
|
13
14
|
"lib-utils",
|
|
14
|
-
"select"
|
|
15
|
+
"select",
|
|
16
|
+
"tag-input"
|
|
15
17
|
],
|
|
16
18
|
"files": [
|
|
17
19
|
{
|
|
18
20
|
"path": "components/ui/segment-builder.tsx",
|
|
19
21
|
"type": "registry:ui",
|
|
20
|
-
"content": "import { useId, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Plus, X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Select } from '@/components/ui/select'\nimport { fieldBase, fieldOutline, focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Build an audience from conditions, with precedence visible rather than implied.\n *\n * **The join is a rail, not a column of dropdowns.** A flat list with an and/or\n * select on every line is ambiguous — \"A or B and C\" is two different audiences\n * depending on how it binds, and people read it the way English reads, not the\n * way the engine evaluates. Here a group has exactly one join, shown once on a\n * bracket down its left edge and clickable there. The bracket is the parse\n * tree: what it spans is what the join applies to, and nesting is the only way\n * to say anything else.\n *\n * **There is a plain-language summary underneath.** It is the cheapest possible\n * check on the thing that goes wrong — parentheses — and it costs one pass over\n * a tree the component already holds. If the sentence is not the audience you\n * meant, the query is not either.\n *\n * **The output is a structure, not a string.** `onChange` hands back nested\n * groups, so it compiles to SQL or an API filter with values still separated\n * from operators, which is what keeps it parameterisable rather than\n * concatenated into an injection.\n *\n * **Incomplete rows are marked, not silently dropped.** A condition with no\n * value is a half-written thought; treating it as absent means the segment\n * quietly matches more people than the person building it believes, which for a\n * marketing send is an expensive kind of wrong.\n *\n * The estimate is a prop, not something computed here — only your backend knows\n * how many people match.\n */\nexport type FieldSpec = {\n key: string\n label: string\n type?: 'string' | 'number' | 'date' | 'boolean' | 'enum'\n /** For `enum`, the allowed values. */\n options?: { value: string; label: string }[]\n}\n\nexport type Condition = {\n id: string\n field: string\n operator: string\n value: string\n}\n\nexport type ConditionGroup = {\n id: string\n join: 'and' | 'or'\n conditions: (Condition | ConditionGroup)[]\n}\n\nconst isGroup = (node: Condition | ConditionGroup): node is ConditionGroup => 'conditions' in node\n\ntype Operator = { value: string; label: string; unary?: boolean }\n\nconst OPERATORS: Record<string, Operator[]> = {\n string: [\n { value: 'eq', label: 'is' },\n { value: 'neq', label: 'is not' },\n { value: 'contains', label: 'contains' },\n { value: 'starts', label: 'starts with' },\n { value: 'set', label: 'is set', unary: true },\n { value: 'unset', label: 'is not set', unary: true },\n ],\n number: [\n { value: 'eq', label: '=' },\n { value: 'neq', label: '≠' },\n { value: 'gt', label: '>' },\n { value: 'gte', label: '≥' },\n { value: 'lt', label: '<' },\n { value: 'lte', label: '≤' },\n ],\n date: [\n { value: 'after', label: 'after' },\n { value: 'before', label: 'before' },\n { value: 'within', label: 'in the last (days)' },\n ],\n boolean: [\n { value: 'true', label: 'is true', unary: true },\n { value: 'false', label: 'is false', unary: true },\n ],\n enum: [\n { value: 'eq', label: 'is' },\n { value: 'neq', label: 'is not' },\n { value: 'in', label: 'is one of' },\n ],\n}\n\n/**\n * Everything the recursive rows need, passed down as one object.\n *\n * The subcomponents are declared at module scope rather than inside the\n * builder: a component defined during render is a *new type* on every render,\n * so React unmounts and remounts the whole subtree — losing focus mid-keystroke\n * and resetting anything stateful inside it.\n */\ntype Ctx = {\n fields: FieldSpec[]\n disabled?: boolean\n maxDepth: number\n addLabel: string\n addGroupLabel: string\n emptyLabel: string\n patch: (id: string, changes: Partial<Condition>) => void\n drop: (id: string) => void\n setJoin: (id: string, join: 'and' | 'or') => void\n addCondition: (id: string) => void\n addGroup: (id: string) => void\n}\n\nconst operatorsFor = (fields: FieldSpec[], key: string) =>\n OPERATORS[fields.find((field) => field.key === key)?.type ?? 'string'] ?? OPERATORS.string\n\n/* --------------------------------------------------------------- summary */\n\n/** The tree as a sentence, with real parentheses. */\nfunction describe(node: Condition | ConditionGroup, fields: FieldSpec[], top = true): string {\n if (!isGroup(node)) {\n const spec = fields.find((field) => field.key === node.field)\n const operator = operatorsFor(fields, node.field).find((item) => item.value === node.operator)\n const label = spec?.label ?? node.field\n if (operator?.unary) return `${label} ${operator.label}`\n const shown =\n spec?.type === 'enum'\n ? (spec.options?.find((option) => option.value === node.value)?.label ?? node.value)\n : node.value\n return `${label} ${operator?.label ?? node.operator} ${shown || '…'}`\n }\n\n if (node.conditions.length === 0) return 'everyone'\n const parts = node.conditions.map((child) => describe(child, fields, false))\n const joined = parts.join(` ${node.join} `)\n // Only nested groups get brackets; wrapping the whole thing adds noise.\n return top || parts.length < 2 ? joined : `(${joined})`\n}\n\n/* ------------------------------------------------------------------ row */\n\nfunction ConditionRow({ condition, ctx }: { condition: Condition; ctx: Ctx }) {\n const operators = operatorsFor(ctx.fields, condition.field)\n const operator = operators.find((item) => item.value === condition.operator)\n const spec = ctx.fields.find((field) => field.key === condition.field)\n const incomplete = !operator?.unary && condition.value.trim() === ''\n\n const control = cn(fieldBase, fieldOutline, 'h-8 w-full min-w-0 px-2 text-sm')\n\n return (\n <div\n className={cn(\n 'grid items-center gap-2',\n // Container queries, not viewport ones: this sits in a sidebar as often\n // as a full page, and the breakpoint that matters is its own width.\n '@[30rem]:grid-cols-[minmax(6rem,1fr)_minmax(5rem,auto)_minmax(6rem,1.2fr)_auto]',\n )}\n >\n {/*\n The kit's own `Select`, not a native one.\n\n A native `<select>` cannot be styled to match the rest of the kit — the\n popup is drawn by the OS — so a builder built from them looks like a\n different product wherever it is embedded. `Select` also brings the\n typeahead, roving focus and popper placement the rest of the kit has,\n which matters more here than anywhere: these rows nest inside scrolling\n containers where a naively positioned menu gets clipped.\n */}\n <Select\n size=\"sm\"\n triggerLabel=\"Field\"\n disabled={ctx.disabled}\n value={condition.field}\n options={ctx.fields.map((field) => ({ value: field.key, label: field.label }))}\n onValueChange={(next) =>\n ctx.patch(condition.id, {\n field: next,\n // The operator list changes with the type, so a stale operator\n // would be unselectable and the value meaningless.\n operator: operatorsFor(ctx.fields, next)[0]?.value ?? 'eq',\n value: '',\n })\n }\n triggerClassName=\"w-full font-medium\"\n />\n\n <Select\n size=\"sm\"\n triggerLabel=\"Operator\"\n disabled={ctx.disabled}\n value={condition.operator}\n options={operators.map((item) => ({ value: item.value, label: item.label }))}\n onValueChange={(next) => ctx.patch(condition.id, { operator: next })}\n triggerClassName=\"text-muted-foreground w-full\"\n />\n\n {operator?.unary ? (\n <span aria-hidden=\"true\" className=\"hidden @[30rem]:block\" />\n ) : spec?.type === 'enum' && spec.options ? (\n <Select\n size=\"sm\"\n triggerLabel=\"Value\"\n placeholder=\"Choose…\"\n error={incomplete || undefined}\n disabled={ctx.disabled}\n value={condition.value}\n options={spec.options.map((option) => ({ value: option.value, label: option.label }))}\n onValueChange={(next) => ctx.patch(condition.id, { value: next })}\n triggerClassName=\"w-full\"\n />\n ) : (\n <input\n aria-label=\"Value\"\n disabled={ctx.disabled}\n aria-invalid={incomplete || undefined}\n type={spec?.type === 'number' || spec?.type === 'date' ? spec.type : 'text'}\n placeholder=\"Value\"\n value={condition.value}\n onChange={(event) => ctx.patch(condition.id, { value: event.target.value })}\n // Marked, never silently ignored: an empty value matches more people\n // than the author thinks.\n className={cn(control, incomplete && 'border-[var(--destructive)]')}\n />\n )}\n\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n disabled={ctx.disabled}\n aria-label=\"Remove condition\"\n className=\"justify-self-end\"\n onClick={() => ctx.drop(condition.id)}\n >\n <X />\n </Button>\n </div>\n )\n}\n\n/* ---------------------------------------------------------------- group */\n\nfunction Group({ group, depth, ctx }: { group: ConditionGroup; depth: number; ctx: Ctx }) {\n const many = group.conditions.length > 1\n\n return (\n <div\n data-slot=\"segment-group\"\n data-join={group.join}\n className={cn(\n '@container',\n depth > 0 && cn(radius.surface, 'border-border bg-muted/30 border p-2'),\n )}\n >\n <div className=\"flex gap-2\">\n {/*\n The bracket. What it spans is what the join applies to — which is the\n one thing a flat list of and/or dropdowns cannot show.\n */}\n <div className={cn('relative w-11 shrink-0', !many && 'w-0 overflow-hidden')}>\n {many && (\n <>\n <span\n aria-hidden=\"true\"\n className=\"border-border absolute inset-y-4 end-0 w-2.5 rounded-s-md border-y border-s\"\n />\n <button\n type=\"button\"\n disabled={ctx.disabled}\n // One control, two states: a labelled toggle rather than a\n // dropdown, because there are only ever two joins.\n aria-label={`Join: ${group.join.toUpperCase()}. Switch to ${\n group.join === 'and' ? 'OR' : 'AND'\n }`}\n onClick={() => ctx.setJoin(group.id, group.join === 'and' ? 'or' : 'and')}\n className={cn(\n 'bg-background border-border absolute top-1/2 start-0 -translate-y-1/2 border',\n 'px-1.5 py-0.5 font-mono text-[10px] font-medium tracking-wider uppercase',\n radius.xs,\n focusRing,\n 'hover:bg-muted disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n {group.join}\n </button>\n </>\n )}\n </div>\n\n <ul className=\"min-w-0 flex-1 list-none space-y-2\">\n {group.conditions.length === 0 ? (\n <li className=\"text-muted-foreground py-1 text-xs\">{ctx.emptyLabel}</li>\n ) : (\n group.conditions.map((node) => (\n <li key={node.id} className=\"min-w-0\">\n {isGroup(node) ? (\n <Group group={node} depth={depth + 1} ctx={ctx} />\n ) : (\n <ConditionRow condition={node} ctx={ctx} />\n )}\n </li>\n ))\n )}\n\n <li className=\"flex flex-wrap items-center gap-1 pt-1\">\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={ctx.disabled}\n onClick={() => ctx.addCondition(group.id)}\n >\n <Plus />\n {ctx.addLabel}\n </Button>\n\n {depth < ctx.maxDepth && (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={ctx.disabled}\n onClick={() => ctx.addGroup(group.id)}\n >\n <Plus />\n {ctx.addGroupLabel}\n </Button>\n )}\n\n {depth > 0 && (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={ctx.disabled}\n className=\"text-muted-foreground ms-auto\"\n onClick={() => ctx.drop(group.id)}\n >\n <X />\n Remove group\n </Button>\n )}\n </li>\n </ul>\n </div>\n </div>\n )\n}\n\n/* -------------------------------------------------------------- builder */\n\ntype SegmentBuilderProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n fields: FieldSpec[]\n value?: ConditionGroup\n defaultValue?: ConditionGroup\n onChange?: (group: ConditionGroup) => void\n /** Matching people, from your backend. */\n estimate?: ReactNode\n /** Depth of nesting allowed. */\n maxDepth?: number\n /** Hide the plain-language summary. */\n summary?: boolean\n addLabel?: string\n addGroupLabel?: string\n label?: string\n emptyLabel?: string\n disabled?: boolean\n}\n\nlet counter = 0\nconst nextId = () => `c${++counter}`\nconst emptyGroup = (): ConditionGroup => ({ id: nextId(), join: 'and', conditions: [] })\n\nfunction SegmentBuilder({\n fields,\n value,\n defaultValue,\n onChange,\n estimate,\n maxDepth = 2,\n summary = true,\n addLabel = 'Condition',\n addGroupLabel = 'Group',\n label = 'Segment',\n emptyLabel = 'No conditions — this matches everyone.',\n disabled,\n className,\n ...props\n}: SegmentBuilderProps) {\n const titleId = useId()\n const [internal, setInternal] = useState<ConditionGroup>(defaultValue ?? emptyGroup())\n\n const root = value ?? internal\n\n const commit = (next: ConditionGroup) => {\n if (value === undefined) setInternal(next)\n onChange?.(next)\n }\n\n /* -------------------------------------------- structural edits, by id */\n\n const editGroup = (\n group: ConditionGroup,\n id: string,\n fn: (node: ConditionGroup) => ConditionGroup,\n ): ConditionGroup =>\n group.id === id\n ? fn(group)\n : {\n ...group,\n conditions: group.conditions.map((node) =>\n isGroup(node) ? editGroup(node, id, fn) : node,\n ),\n }\n\n const editCondition = (\n group: ConditionGroup,\n id: string,\n changes: Partial<Condition>,\n ): ConditionGroup => ({\n ...group,\n conditions: group.conditions.map((node) =>\n isGroup(node)\n ? editCondition(node, id, changes)\n : node.id === id\n ? { ...node, ...changes }\n : node,\n ),\n })\n\n const removeNode = (group: ConditionGroup, id: string): ConditionGroup => ({\n ...group,\n conditions: group.conditions\n .filter((node) => node.id !== id)\n .map((node) => (isGroup(node) ? removeNode(node, id) : node)),\n })\n\n const ctx: Ctx = {\n fields,\n disabled,\n maxDepth,\n addLabel,\n addGroupLabel,\n emptyLabel,\n patch: (id, changes) => commit(editCondition(root, id, changes)),\n drop: (id) => commit(removeNode(root, id)),\n setJoin: (id, join) => commit(editGroup(root, id, (current) => ({ ...current, join }))),\n addCondition: (id) =>\n commit(\n editGroup(root, id, (current) => ({\n ...current,\n conditions: [\n ...current.conditions,\n {\n id: nextId(),\n field: fields[0]?.key ?? '',\n operator: operatorsFor(fields, fields[0]?.key ?? '')[0]?.value ?? 'eq',\n value: '',\n },\n ],\n })),\n ),\n addGroup: (id) =>\n commit(\n editGroup(root, id, (current) => ({\n ...current,\n conditions: [...current.conditions, emptyGroup()],\n })),\n ),\n }\n\n return (\n <div\n data-slot=\"segment-builder\"\n className={cn('flex flex-col gap-2', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-2\">\n <p id={titleId} className=\"text-sm font-medium\">\n {label}\n </p>\n {estimate !== undefined && (\n <p className=\"text-muted-foreground text-xs tabular-nums\">{estimate}</p>\n )}\n </div>\n\n <div className={cn(surface, radius.surface, 'p-3')}>\n <Group group={root} depth={0} ctx={ctx} />\n </div>\n\n {summary && (\n <p className=\"text-muted-foreground text-xs leading-relaxed\">\n <span className=\"font-medium\">Matches: </span>\n <span className=\"font-mono\">{describe(root, fields)}</span>\n </p>\n )}\n </div>\n )\n}\n\nexport { SegmentBuilder, describe as describeSegment }\nexport type { SegmentBuilderProps }\n"
|
|
22
|
+
"content": "import { useId, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Plus, X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Select } from '@/components/ui/select'\nimport { TagInput } from '@/components/ui/tag-input'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Build an audience from conditions, with precedence visible rather than implied.\n *\n * **The join is a rail, not a column of dropdowns.** A flat list with an and/or\n * select on every line is ambiguous — \"A or B and C\" is two different audiences\n * depending on how it binds, and people read it the way English reads, not the\n * way the engine evaluates. Here a group has exactly one join, shown once on a\n * bracket down its left edge and clickable there. The bracket is the parse\n * tree: what it spans is what the join applies to, and nesting is the only way\n * to say anything else.\n *\n * **There is a plain-language summary underneath.** It is the cheapest possible\n * check on the thing that goes wrong — parentheses — and it costs one pass over\n * a tree the component already holds. If the sentence is not the audience you\n * meant, the query is not either.\n *\n * **The output is a structure, not a string.** `onChange` hands back nested\n * groups, so it compiles to SQL or an API filter with values still separated\n * from operators, which is what keeps it parameterisable rather than\n * concatenated into an injection.\n *\n * **Incomplete rows are marked, not silently dropped.** A condition with no\n * value is a half-written thought; treating it as absent means the segment\n * quietly matches more people than the person building it believes, which for a\n * marketing send is an expensive kind of wrong.\n *\n * The estimate is a prop, not something computed here — only your backend knows\n * how many people match.\n */\nexport type FieldSpec = {\n key: string\n label: string\n type?: 'string' | 'number' | 'date' | 'boolean' | 'enum'\n /** For `enum`, the allowed values. */\n options?: { value: string; label: string }[]\n /**\n * Operators for this field, replacing the ones its `type` implies.\n *\n * A caller that knows more about the field than a type name conveys — a SQL\n * dialect with `ILIKE`, a metric with a percentile comparison — supplies them\n * rather than being limited to the built-in list.\n */\n operators?: Operator[]\n}\n\nexport type Condition = {\n id: string\n field: string\n operator: string\n value: string\n /** For `many` operators — \"is one of\", `IN`. */\n values?: string[]\n}\n\nexport type ConditionGroup = {\n id: string\n join: 'and' | 'or'\n conditions: (Condition | ConditionGroup)[]\n}\n\nconst isGroup = (node: Condition | ConditionGroup): node is ConditionGroup => 'conditions' in node\n\nexport type Operator = {\n value: string\n label: string\n /**\n * How many values the operator takes.\n *\n * `none` is a complete predicate on its own (\"is set\"); `one` is the ordinary\n * case; `many` takes a list and is what \"is one of\" always meant — it was in\n * this list from the start, rendered as a single select, which quietly made\n * it a synonym for \"is\".\n */\n arity?: 'none' | 'one' | 'many'\n /** @deprecated Use `arity: 'none'`. */\n unary?: boolean\n}\n\nconst arityOf = (operator: Operator | undefined) =>\n operator?.arity ?? (operator?.unary ? 'none' : 'one')\n\nconst OPERATORS: Record<string, Operator[]> = {\n string: [\n { value: 'eq', label: 'is' },\n { value: 'neq', label: 'is not' },\n { value: 'contains', label: 'contains' },\n { value: 'starts', label: 'starts with' },\n { value: 'in', label: 'is one of', arity: 'many' },\n { value: 'set', label: 'is set', arity: 'none' },\n { value: 'unset', label: 'is not set', arity: 'none' },\n ],\n number: [\n { value: 'eq', label: '=' },\n { value: 'neq', label: '≠' },\n { value: 'gt', label: '>' },\n { value: 'gte', label: '≥' },\n { value: 'lt', label: '<' },\n { value: 'lte', label: '≤' },\n { value: 'in', label: 'is one of', arity: 'many' },\n ],\n date: [\n { value: 'after', label: 'after' },\n { value: 'before', label: 'before' },\n { value: 'within', label: 'in the last (days)' },\n ],\n boolean: [\n { value: 'true', label: 'is true', arity: 'none' },\n { value: 'false', label: 'is false', arity: 'none' },\n ],\n enum: [\n { value: 'eq', label: 'is' },\n { value: 'neq', label: 'is not' },\n { value: 'in', label: 'is one of', arity: 'many' },\n ],\n}\n\n/**\n * Everything the recursive rows need, passed down as one object.\n *\n * The subcomponents are declared at module scope rather than inside the\n * builder: a component defined during render is a *new type* on every render,\n * so React unmounts and remounts the whole subtree — losing focus mid-keystroke\n * and resetting anything stateful inside it.\n */\ntype Ctx = {\n fields: FieldSpec[]\n disabled?: boolean\n maxDepth: number\n addLabel: string\n addGroupLabel: string\n emptyLabel: string\n patch: (id: string, changes: Partial<Condition>) => void\n drop: (id: string) => void\n setJoin: (id: string, join: 'and' | 'or') => void\n addCondition: (id: string) => void\n addGroup: (id: string) => void\n}\n\nconst operatorsFor = (fields: FieldSpec[], key: string) => {\n const field = fields.find((item) => item.key === key)\n return field?.operators ?? OPERATORS[field?.type ?? 'string'] ?? OPERATORS.string\n}\n\n/* --------------------------------------------------------------- summary */\n\n/** The tree as a sentence, with real parentheses. */\nfunction describe(node: Condition | ConditionGroup, fields: FieldSpec[], top = true): string {\n if (!isGroup(node)) {\n const spec = fields.find((field) => field.key === node.field)\n const operator = operatorsFor(fields, node.field).find((item) => item.value === node.operator)\n const label = spec?.label ?? node.field\n const arity = arityOf(operator)\n if (arity === 'none') return `${label} ${operator?.label}`\n if (arity === 'many') {\n const list = node.values ?? []\n return `${label} ${operator?.label ?? node.operator} ${list.length ? list.join(', ') : '…'}`\n }\n const shown =\n spec?.type === 'enum'\n ? (spec.options?.find((option) => option.value === node.value)?.label ?? node.value)\n : node.value\n return `${label} ${operator?.label ?? node.operator} ${shown || '…'}`\n }\n\n if (node.conditions.length === 0) return 'everyone'\n const parts = node.conditions.map((child) => describe(child, fields, false))\n const joined = parts.join(` ${node.join} `)\n // Only nested groups get brackets; wrapping the whole thing adds noise.\n return top || parts.length < 2 ? joined : `(${joined})`\n}\n\n/* ------------------------------------------------------------------ row */\n\nfunction ConditionRow({ condition, ctx }: { condition: Condition; ctx: Ctx }) {\n const operators = operatorsFor(ctx.fields, condition.field)\n const operator = operators.find((item) => item.value === condition.operator)\n const spec = ctx.fields.find((field) => field.key === condition.field)\n const arity = arityOf(operator)\n const incomplete =\n arity === 'many'\n ? (condition.values ?? []).length === 0\n : arity === 'one' && condition.value.trim() === ''\n\n return (\n <div\n className={cn(\n 'grid items-center gap-2',\n // Container queries, not viewport ones: this sits in a sidebar as often\n // as a full page, and the breakpoint that matters is its own width.\n // 20rem, not 30: every level of nesting takes a rail and padding off\n // the width, so a breakpoint tuned to the outermost group leaves the\n // inner ones stacked — which is exactly where the rows are hardest to\n // read.\n '@[20rem]:grid-cols-[minmax(5rem,1fr)_minmax(4rem,auto)_minmax(5rem,1.1fr)_auto]',\n )}\n >\n {/*\n The kit's own `Select`, not a native one.\n\n A native `<select>` cannot be styled to match the rest of the kit — the\n popup is drawn by the OS — so a builder built from them looks like a\n different product wherever it is embedded. `Select` also brings the\n typeahead, roving focus and popper placement the rest of the kit has,\n which matters more here than anywhere: these rows nest inside scrolling\n containers where a naively positioned menu gets clipped.\n */}\n <Select\n size=\"sm\"\n triggerLabel=\"Field\"\n disabled={ctx.disabled}\n value={condition.field}\n options={ctx.fields.map((field) => ({ value: field.key, label: field.label }))}\n onValueChange={(next) =>\n ctx.patch(condition.id, {\n field: next,\n // The operator list changes with the type, so a stale operator\n // would be unselectable and the value meaningless.\n operator: operatorsFor(ctx.fields, next)[0]?.value ?? 'eq',\n value: '',\n values: [],\n })\n }\n triggerClassName=\"w-full font-medium\"\n />\n\n <Select\n size=\"sm\"\n triggerLabel=\"Operator\"\n disabled={ctx.disabled}\n value={condition.operator}\n options={operators.map((item) => ({ value: item.value, label: item.label }))}\n onValueChange={(next) => {\n // Moving between arities leaves the other side stale, and a stale\n // value silently widens the segment when the operator moves back.\n const to = arityOf(operators.find((item) => item.value === next))\n ctx.patch(condition.id, {\n operator: next,\n ...(to === 'many' ? { value: '' } : { values: [] }),\n })\n }}\n triggerClassName=\"text-muted-foreground w-full\"\n />\n\n {arity === 'none' ? (\n <span aria-hidden=\"true\" className=\"hidden @[20rem]:block\" />\n ) : arity === 'many' ? (\n /* A list, entered as tags. \"Is one of\" with a single-value control was\n only ever a slower way of writing \"is\". */\n <TagInput\n size=\"sm\"\n value={condition.values ?? []}\n disabled={ctx.disabled}\n error={incomplete}\n placeholder=\"Add a value\"\n aria-label=\"Values\"\n onValueChange={(next) => ctx.patch(condition.id, { values: next })}\n />\n ) : spec?.type === 'enum' && spec.options ? (\n <Select\n size=\"sm\"\n triggerLabel=\"Value\"\n placeholder=\"Choose…\"\n error={incomplete || undefined}\n disabled={ctx.disabled}\n value={condition.value}\n options={spec.options.map((option) => ({ value: option.value, label: option.label }))}\n onValueChange={(next) => ctx.patch(condition.id, { value: next })}\n triggerClassName=\"w-full\"\n />\n ) : (\n <Input\n size=\"sm\"\n aria-label=\"Value\"\n disabled={ctx.disabled}\n // Marked, never silently ignored: an empty value matches more people\n // than the author thinks. `error` is the kit's own invalid state, so\n // it stays red while you type in it.\n error={incomplete}\n type={spec?.type === 'number' || spec?.type === 'date' ? spec.type : 'text'}\n placeholder=\"Value\"\n value={condition.value}\n onChange={(event) => ctx.patch(condition.id, { value: event.target.value })}\n containerClassName=\"w-full min-w-0\"\n />\n )}\n\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n disabled={ctx.disabled}\n aria-label=\"Remove condition\"\n className=\"justify-self-end\"\n onClick={() => ctx.drop(condition.id)}\n >\n <X />\n </Button>\n </div>\n )\n}\n\n/* ---------------------------------------------------------------- group */\n\nfunction Group({ group, depth, ctx }: { group: ConditionGroup; depth: number; ctx: Ctx }) {\n const many = group.conditions.length > 1\n\n return (\n <div\n data-slot=\"segment-group\"\n data-join={group.join}\n className={cn(\n '@container',\n depth > 0 && cn(radius.surface, 'border-border bg-muted/30 border p-2'),\n )}\n >\n <div className=\"flex gap-2\">\n {/*\n The bracket. What it spans is what the join applies to — which is the\n one thing a flat list of and/or dropdowns cannot show.\n */}\n <div className={cn('relative w-11 shrink-0', !many && 'w-0 overflow-hidden')}>\n {many && (\n <>\n <span\n aria-hidden=\"true\"\n className=\"border-border absolute inset-y-4 end-0 w-2.5 rounded-s-md border-y border-s\"\n />\n <button\n type=\"button\"\n disabled={ctx.disabled}\n // One control, two states: a labelled toggle rather than a\n // dropdown, because there are only ever two joins.\n aria-label={`Join: ${group.join.toUpperCase()}. Switch to ${\n group.join === 'and' ? 'OR' : 'AND'\n }`}\n onClick={() => ctx.setJoin(group.id, group.join === 'and' ? 'or' : 'and')}\n className={cn(\n 'bg-background border-border absolute top-1/2 start-0 -translate-y-1/2 border',\n 'px-1.5 py-0.5 font-mono text-[10px] font-medium tracking-wider uppercase',\n radius.xs,\n focusRing,\n 'hover:bg-muted disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n {group.join}\n </button>\n </>\n )}\n </div>\n\n <ul className=\"min-w-0 flex-1 list-none space-y-2\">\n {group.conditions.length === 0 ? (\n <li className=\"text-muted-foreground py-1 text-xs\">{ctx.emptyLabel}</li>\n ) : (\n group.conditions.map((node) => (\n <li key={node.id} className=\"min-w-0\">\n {isGroup(node) ? (\n <Group group={node} depth={depth + 1} ctx={ctx} />\n ) : (\n <ConditionRow condition={node} ctx={ctx} />\n )}\n </li>\n ))\n )}\n\n <li className=\"flex flex-wrap items-center gap-1 pt-1\">\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={ctx.disabled}\n onClick={() => ctx.addCondition(group.id)}\n >\n <Plus />\n {ctx.addLabel}\n </Button>\n\n {depth < ctx.maxDepth && (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={ctx.disabled}\n onClick={() => ctx.addGroup(group.id)}\n >\n <Plus />\n {ctx.addGroupLabel}\n </Button>\n )}\n\n {depth > 0 && (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={ctx.disabled}\n className=\"text-muted-foreground ms-auto\"\n onClick={() => ctx.drop(group.id)}\n >\n <X />\n Remove group\n </Button>\n )}\n </li>\n </ul>\n </div>\n </div>\n )\n}\n\n/* -------------------------------------------------------------- builder */\n\ntype SegmentBuilderProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n fields: FieldSpec[]\n value?: ConditionGroup\n defaultValue?: ConditionGroup\n onChange?: (group: ConditionGroup) => void\n /** Matching people, from your backend. */\n estimate?: ReactNode\n /** Depth of nesting allowed. */\n maxDepth?: number\n /** Hide the plain-language summary. */\n summary?: boolean\n addLabel?: string\n addGroupLabel?: string\n label?: string\n emptyLabel?: string\n disabled?: boolean\n}\n\nlet counter = 0\nconst nextId = () => `c${++counter}`\nconst emptyGroup = (): ConditionGroup => ({ id: nextId(), join: 'and', conditions: [] })\n\nfunction SegmentBuilder({\n fields,\n value,\n defaultValue,\n onChange,\n estimate,\n maxDepth = 2,\n summary = true,\n addLabel = 'Condition',\n addGroupLabel = 'Group',\n label = 'Segment',\n emptyLabel = 'No conditions — this matches everyone.',\n disabled,\n className,\n ...props\n}: SegmentBuilderProps) {\n const titleId = useId()\n const [internal, setInternal] = useState<ConditionGroup>(defaultValue ?? emptyGroup())\n\n const root = value ?? internal\n\n const commit = (next: ConditionGroup) => {\n if (value === undefined) setInternal(next)\n onChange?.(next)\n }\n\n /* -------------------------------------------- structural edits, by id */\n\n const editGroup = (\n group: ConditionGroup,\n id: string,\n fn: (node: ConditionGroup) => ConditionGroup,\n ): ConditionGroup =>\n group.id === id\n ? fn(group)\n : {\n ...group,\n conditions: group.conditions.map((node) =>\n isGroup(node) ? editGroup(node, id, fn) : node,\n ),\n }\n\n const editCondition = (\n group: ConditionGroup,\n id: string,\n changes: Partial<Condition>,\n ): ConditionGroup => ({\n ...group,\n conditions: group.conditions.map((node) =>\n isGroup(node)\n ? editCondition(node, id, changes)\n : node.id === id\n ? { ...node, ...changes }\n : node,\n ),\n })\n\n const removeNode = (group: ConditionGroup, id: string): ConditionGroup => ({\n ...group,\n conditions: group.conditions\n .filter((node) => node.id !== id)\n .map((node) => (isGroup(node) ? removeNode(node, id) : node)),\n })\n\n const ctx: Ctx = {\n fields,\n disabled,\n maxDepth,\n addLabel,\n addGroupLabel,\n emptyLabel,\n patch: (id, changes) => commit(editCondition(root, id, changes)),\n drop: (id) => commit(removeNode(root, id)),\n setJoin: (id, join) => commit(editGroup(root, id, (current) => ({ ...current, join }))),\n addCondition: (id) =>\n commit(\n editGroup(root, id, (current) => ({\n ...current,\n conditions: [\n ...current.conditions,\n {\n id: nextId(),\n field: fields[0]?.key ?? '',\n operator: operatorsFor(fields, fields[0]?.key ?? '')[0]?.value ?? 'eq',\n value: '',\n },\n ],\n })),\n ),\n addGroup: (id) =>\n commit(\n editGroup(root, id, (current) => ({\n ...current,\n conditions: [...current.conditions, emptyGroup()],\n })),\n ),\n }\n\n return (\n <div\n data-slot=\"segment-builder\"\n className={cn('flex flex-col gap-2', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-2\">\n <p id={titleId} className=\"text-sm font-medium\">\n {label}\n </p>\n {estimate !== undefined && (\n <p className=\"text-muted-foreground text-xs tabular-nums\">{estimate}</p>\n )}\n </div>\n\n <div className={cn(surface, radius.surface, 'p-3')}>\n <Group group={root} depth={0} ctx={ctx} />\n </div>\n\n {summary && (\n <p className=\"text-muted-foreground text-xs leading-relaxed\">\n <span className=\"font-medium\">Matches: </span>\n <span className=\"font-mono\">{describe(root, fields)}</span>\n </p>\n )}\n </div>\n )\n}\n\nexport { SegmentBuilder, describe as describeSegment }\nexport type { SegmentBuilderProps }\n"
|
|
21
23
|
}
|
|
22
24
|
]
|
|
23
25
|
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
11
|
"button",
|
|
12
|
+
"checkbox",
|
|
13
|
+
"input",
|
|
12
14
|
"lib-styles",
|
|
13
15
|
"lib-utils"
|
|
14
16
|
],
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
{
|
|
17
19
|
"path": "components/ui/transfer.tsx",
|
|
18
20
|
"type": "registry:ui",
|
|
19
|
-
"content": "import { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronLeft, ChevronRight, Search } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport {
|
|
21
|
+
"content": "import { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronLeft, ChevronRight, Search } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Input } from '@/components/ui/input'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Two lists and the movement between them: choose a subset from a large pool.\n *\n * **The reason this exists rather than a multi-select** is that it shows the\n * chosen set as a *list* instead of a row of chips. Once a selection passes\n * roughly a dozen items, chips wrap into a wall you cannot scan, cannot search\n * and cannot sort — and the question \"did I already add Maria?\" stops being\n * answerable at a glance. Permissions, mailing lists and column pickers are all\n * this shape.\n *\n * **Both sides are searchable independently.** Filtering the source is obvious;\n * filtering the target is what makes a 200-item selection usable, and it is the\n * half most implementations leave out.\n *\n * Each side is a listbox with `aria-multiselectable`, and moving items is a\n * button — not a double-click only, which is undiscoverable and impossible on a\n * keyboard. Selection state is per-side and clears after a move, because the\n * items are no longer where you left them.\n */\nexport type TransferItem = {\n value: string\n label: ReactNode\n /** Searched against. Falls back to `label` when it is a string. */\n keywords?: string\n disabled?: boolean\n}\n\ntype TransferProps = Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'> & {\n items: TransferItem[]\n /** Values on the right-hand side. */\n value?: string[]\n defaultValue?: string[]\n onChange?: (value: string[]) => void\n titles?: [ReactNode, ReactNode]\n searchable?: boolean\n height?: number\n searchPlaceholder?: string\n emptyLabel?: string\n toTargetLabel?: string\n toSourceLabel?: string\n /** Rendered under each title. Defaults to \"n of m\". */\n countLabel?: (selected: number, total: number) => ReactNode\n disabled?: boolean\n}\n\nconst text = (item: TransferItem) =>\n item.keywords ?? (typeof item.label === 'string' ? item.label : item.value)\n\nfunction Panel({\n title,\n items,\n picked,\n onToggle,\n searchable,\n height,\n searchPlaceholder,\n emptyLabel,\n countLabel,\n disabled,\n}: {\n title: ReactNode\n items: TransferItem[]\n picked: Set<string>\n onToggle: (value: string) => void\n searchable: boolean\n height: number\n searchPlaceholder: string\n emptyLabel: string\n countLabel: (selected: number, total: number) => ReactNode\n disabled?: boolean\n}) {\n const scope = useId()\n const [query, setQuery] = useState('')\n\n const shown = useMemo(() => {\n const needle = query.trim().toLowerCase()\n if (!needle) return items\n return items.filter((item) => text(item).toLowerCase().includes(needle))\n }, [items, query])\n\n return (\n <div className={cn('flex min-w-0 flex-1 flex-col', surface, radius.surface)}>\n <div className=\"border-border border-b px-3 py-2\">\n <p className=\"truncate text-sm font-medium\">{title}</p>\n <p className=\"text-muted-foreground text-xs\">{countLabel(picked.size, items.length)}</p>\n </div>\n\n {searchable && (\n <div className=\"border-border border-b p-2\">\n <Input\n size=\"sm\"\n icon={<Search />}\n value={query}\n disabled={disabled}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={searchPlaceholder}\n aria-label={searchPlaceholder}\n />\n </div>\n )}\n\n <ul\n role=\"listbox\"\n aria-multiselectable=\"true\"\n aria-label={typeof title === 'string' ? title : undefined}\n style={{ height }}\n className=\"min-h-0 flex-1 list-none overflow-y-auto p-1\"\n >\n {shown.length === 0 ? (\n <li className=\"text-muted-foreground p-3 text-xs\">{emptyLabel}</li>\n ) : (\n shown.map((item) => {\n const on = picked.has(item.value)\n return (\n <li key={item.value}>\n <label\n className={cn(\n 'flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm',\n radius.xs,\n 'hover:bg-muted',\n on && 'bg-muted',\n (item.disabled || disabled) && 'pointer-events-none opacity-50',\n )}\n >\n <Checkbox\n size=\"sm\"\n checked={on}\n disabled={item.disabled || disabled}\n onChange={() => onToggle(item.value)}\n aria-labelledby={`${scope}-${item.value}`}\n />\n <span id={`${scope}-${item.value}`} className=\"min-w-0 flex-1 truncate\">\n {item.label}\n </span>\n </label>\n </li>\n )\n })\n )}\n </ul>\n </div>\n )\n}\n\nfunction Transfer({\n items,\n value,\n defaultValue = [],\n onChange,\n titles = ['Available', 'Selected'],\n searchable = true,\n height = 240,\n searchPlaceholder = 'Search',\n emptyLabel = 'Nothing here.',\n toTargetLabel = 'Move to selected',\n toSourceLabel = 'Move to available',\n countLabel = (selected, total) => `${selected} of ${total} selected`,\n disabled,\n className,\n ...props\n}: TransferProps) {\n const [internal, setInternal] = useState<string[]>(defaultValue)\n const [sourcePicked, setSourcePicked] = useState<Set<string>>(new Set())\n const [targetPicked, setTargetPicked] = useState<Set<string>>(new Set())\n\n const selected = value ?? internal\n const chosen = useMemo(() => new Set(selected), [selected])\n\n const source = items.filter((item) => !chosen.has(item.value))\n const target = items.filter((item) => chosen.has(item.value))\n\n const commit = (next: string[]) => {\n if (value === undefined) setInternal(next)\n onChange?.(next)\n }\n\n const toggle = (set: Set<string>, update: (next: Set<string>) => void) => (value: string) => {\n const next = new Set(set)\n if (next.has(value)) next.delete(value)\n else next.add(value)\n update(next)\n }\n\n const move = (direction: 'right' | 'left') => {\n if (direction === 'right') {\n commit([...selected, ...[...sourcePicked].filter((v) => !chosen.has(v))])\n // Cleared because those rows are now on the other side.\n setSourcePicked(new Set())\n } else {\n commit(selected.filter((v) => !targetPicked.has(v)))\n setTargetPicked(new Set())\n }\n }\n\n return (\n <div\n data-slot=\"transfer\"\n className={cn('flex flex-col items-stretch gap-3 sm:flex-row sm:items-center', className)}\n {...props}\n >\n <Panel\n title={titles[0]}\n items={source}\n picked={sourcePicked}\n onToggle={toggle(sourcePicked, setSourcePicked)}\n searchable={searchable}\n height={height}\n searchPlaceholder={searchPlaceholder}\n emptyLabel={emptyLabel}\n countLabel={countLabel}\n disabled={disabled}\n />\n\n <div className=\"flex shrink-0 flex-row justify-center gap-2 sm:flex-col\">\n <Button\n size=\"icon-sm\"\n variant=\"secondary\"\n aria-label={toTargetLabel}\n disabled={disabled || sourcePicked.size === 0}\n onClick={() => move('right')}\n >\n <ChevronRight className=\"rtl:rotate-180\" />\n </Button>\n <Button\n size=\"icon-sm\"\n variant=\"secondary\"\n aria-label={toSourceLabel}\n disabled={disabled || targetPicked.size === 0}\n onClick={() => move('left')}\n >\n <ChevronLeft className=\"rtl:rotate-180\" />\n </Button>\n </div>\n\n <Panel\n title={titles[1]}\n items={target}\n picked={targetPicked}\n onToggle={toggle(targetPicked, setTargetPicked)}\n searchable={searchable}\n height={height}\n searchPlaceholder={searchPlaceholder}\n emptyLabel={emptyLabel}\n countLabel={countLabel}\n disabled={disabled}\n />\n </div>\n )\n}\n\nexport { Transfer }\nexport type { TransferProps }\n"
|
|
20
22
|
}
|
|
21
23
|
]
|
|
22
24
|
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"lucide-react@^1.39.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
+
"checkbox",
|
|
12
|
+
"input",
|
|
11
13
|
"lib-styles",
|
|
12
14
|
"lib-utils",
|
|
13
15
|
"primitive-dismissable",
|
|
@@ -17,7 +19,7 @@
|
|
|
17
19
|
{
|
|
18
20
|
"path": "components/ui/tree-select.tsx",
|
|
19
21
|
"type": "registry:ui",
|
|
20
|
-
"content": "import { useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight, Search } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport { fieldBase, fieldOutline, fieldSize, focusRing, menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A tree in a popover, with checkboxes and real tri-state parents.\n *\n * **The tri-state is the whole problem.** A parent whose children are partly\n * chosen is neither checked nor unchecked, and rendering it as unchecked is a\n * lie people act on — they tick it, and silently replace a careful selection\n * with everything. The indeterminate state is set through the DOM property,\n * because `indeterminate` is not an HTML attribute and cannot be expressed in\n * JSX; `aria-checked=\"mixed\"` carries the same fact to assistive technology.\n *\n * **Checking a branch checks its subtree, and `value` holds only the leaves.**\n * Storing branch ids as well means every consumer has to know whether a parent\n * in the list implies its children, and two consumers will answer differently.\n * Leaves are unambiguous, and parent state is derived on render.\n *\n * Searching filters to matching nodes **and keeps their ancestors**, because a\n * result with its path cut off is unplaceable — you cannot tell which \"General\"\n * you found.\n */\nexport type TreeSelectNode = {\n value: string\n label: ReactNode\n children?: TreeSelectNode[]\n disabled?: boolean\n}\n\ntype TreeSelectProps = Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'> & {\n nodes: TreeSelectNode[]\n /** Selected leaf values. */\n value?: string[]\n defaultValue?: string[]\n onChange?: (value: string[]) => void\n placeholder?: string\n searchable?: boolean\n searchPlaceholder?: string\n /** Values of branches open on first render. */\n defaultExpanded?: string[]\n size?: 'sm' | 'md' | 'lg'\n /** Summarises the selection on the trigger. */\n summary?: (values: string[]) => ReactNode\n emptyLabel?: string\n disabled?: boolean\n invalid?: boolean\n label?: string\n}\n\nconst leavesOf = (node: TreeSelectNode): string[] =>\n node.children?.length ? node.children.flatMap(leavesOf) : [node.value]\n\n/** Every value in a subtree, branches included — used to force-open a search. */\nconst allValues = (node: TreeSelectNode): string[] => [\n node.value,\n ...(node.children ?? []).flatMap(allValues),\n]\n\n/** Keep a node if it matches, or if any descendant does. */\nfunction filterTree(nodes: TreeSelectNode[], needle: string): TreeSelectNode[] {\n if (!needle) return nodes\n const out: TreeSelectNode[] = []\n for (const node of nodes) {\n const label = typeof node.label === 'string' ? node.label : node.value\n const kept = node.children ? filterTree(node.children, needle) : undefined\n if (label.toLowerCase().includes(needle) || kept?.length) {\n out.push({ ...node, children: kept?.length ? kept : node.children && [] })\n }\n }\n return out\n}\n\nfunction Row({\n node,\n depth,\n chosen,\n expanded,\n onToggleExpand,\n onToggle,\n disabled,\n}: {\n node: TreeSelectNode\n depth: number\n chosen: Set<string>\n expanded: Set<string>\n onToggleExpand: (value: string) => void\n onToggle: (node: TreeSelectNode, next: boolean) => void\n disabled?: boolean\n}) {\n const leaves = leavesOf(node)\n const picked = leaves.filter((leaf) => chosen.has(leaf)).length\n const all = picked === leaves.length && leaves.length > 0\n const some = picked > 0 && !all\n const branch = Boolean(node.children?.length)\n const open = expanded.has(node.value)\n\n return (\n <li role=\"treeitem\" aria-expanded={branch ? open : undefined} aria-selected={all}>\n <div\n className={cn('flex items-center gap-1.5 py-1 pe-2', radius.xs, 'hover:bg-muted')}\n style={{ paddingInlineStart: `${depth * 16 + 6}px` }}\n >\n {branch ? (\n <button\n type=\"button\"\n aria-label={open ? 'Collapse' : 'Expand'}\n onClick={() => onToggleExpand(node.value)}\n className={cn('text-muted-foreground shrink-0', radius.xs, focusRing)}\n >\n {open ? (\n <ChevronDown className=\"size-3.5\" />\n ) : (\n <ChevronRight className=\"size-3.5 rtl:rotate-180\" />\n )}\n </button>\n ) : (\n <span aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n )}\n\n <label className=\"flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-sm\">\n <input\n type=\"checkbox\"\n checked={all}\n disabled={node.disabled || disabled}\n // `indeterminate` is a property, never an attribute — a ref\n // callback is the only way to express it from JSX.\n ref={(element) => {\n if (element) element.indeterminate = some\n }}\n aria-checked={some ? 'mixed' : all}\n onChange={(event) => onToggle(node, event.target.checked)}\n className={cn('size-3.5 shrink-0 accent-[var(--primary)]', focusRing)}\n />\n <span className=\"min-w-0 flex-1 truncate\">{node.label}</span>\n {branch && (\n <span className=\"text-muted-foreground shrink-0 text-[11px] tabular-nums\">\n {picked}/{leaves.length}\n </span>\n )}\n </label>\n </div>\n\n {branch && open && (\n <ul role=\"group\" className=\"list-none\">\n {node.children?.map((child) => (\n <Row\n key={child.value}\n node={child}\n depth={depth + 1}\n chosen={chosen}\n expanded={expanded}\n onToggleExpand={onToggleExpand}\n onToggle={onToggle}\n disabled={disabled}\n />\n ))}\n </ul>\n )}\n </li>\n )\n}\n\nfunction TreeSelect({\n nodes,\n value,\n defaultValue = [],\n onChange,\n placeholder = 'Select…',\n searchable = true,\n searchPlaceholder = 'Search',\n defaultExpanded = [],\n size = 'md',\n summary,\n emptyLabel = 'Nothing matches.',\n disabled,\n invalid,\n label,\n className,\n ...props\n}: TreeSelectProps) {\n const anchorRef = useRef<HTMLButtonElement>(null)\n const floatingRef = useRef<HTMLDivElement>(null)\n const [open, setOpen] = useState(false)\n const [query, setQuery] = useState('')\n const [internal, setInternal] = useState<string[]>(defaultValue)\n const [expanded, setExpanded] = useState<Set<string>>(new Set(defaultExpanded))\n\n const selected = value ?? internal\n const chosen = useMemo(() => new Set(selected), [selected])\n\n const { style } = usePopper({\n open,\n anchorRef,\n floatingRef,\n side: 'bottom',\n align: 'start',\n matchAnchorWidth: true,\n })\n useDismissable({ open, onDismiss: () => setOpen(false), refs: [anchorRef, floatingRef] })\n\n const shown = useMemo(() => filterTree(nodes, query.trim().toLowerCase()), [nodes, query])\n\n const commit = (next: string[]) => {\n if (value === undefined) setInternal(next)\n onChange?.(next)\n }\n\n const toggle = (node: TreeSelectNode, on: boolean) => {\n const leaves = leavesOf(node)\n const next = new Set(selected)\n for (const leaf of leaves) {\n if (on) next.add(leaf)\n else next.delete(leaf)\n }\n commit([...next])\n }\n\n const toggleExpand = (target: string) => {\n const next = new Set(expanded)\n if (next.has(target)) next.delete(target)\n else next.add(target)\n setExpanded(next)\n }\n\n return (\n <div data-slot=\"tree-select\" className={cn('relative inline-block', className)} {...props}>\n <button\n ref={anchorRef}\n type=\"button\"\n disabled={disabled}\n aria-haspopup=\"tree\"\n aria-expanded={open}\n aria-label={label}\n aria-invalid={invalid || undefined}\n onClick={() => setOpen((current) => !current)}\n className={cn(\n fieldBase, fieldOutline,\n fieldSize[size],\n 'flex w-full items-center justify-between gap-2 text-start',\n invalid && 'border-[var(--destructive)]',\n )}\n >\n <span\n className={cn('min-w-0 flex-1 truncate', selected.length === 0 && 'text-muted-foreground')}\n >\n {selected.length === 0\n ? placeholder\n : (summary?.(selected) ?? `${selected.length} selected`)}\n </span>\n <ChevronDown aria-hidden=\"true\" className=\"text-muted-foreground size-3.5 shrink-0\" />\n </button>\n\n {open && (\n <div\n ref={floatingRef}\n style={style}\n className={cn(menuSurface, radius.surface, 'flex max-h-80 flex-col overflow-hidden p-0')}\n >\n {searchable && (\n <div className=\"border-border border-b p-2\">\n <div className={cn(fieldBase, fieldOutline, 'flex h-8 items-center gap-2 px-2')}>\n <Search aria-hidden=\"true\" className=\"text-muted-foreground size-3.5 shrink-0\" />\n <input\n autoFocus\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={searchPlaceholder}\n aria-label={searchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm outline-none\"\n />\n </div>\n </div>\n )}\n\n <ul role=\"tree\" aria-label={label} className=\"min-h-0 flex-1 list-none overflow-y-auto p-1\">\n {shown.length === 0 ? (\n <li className=\"text-muted-foreground p-2 text-xs\">{emptyLabel}</li>\n ) : (\n shown.map((node) => (\n <Row\n key={node.value}\n node={node}\n depth={0}\n chosen={chosen}\n // While searching every branch is open, or the matches are\n // hidden behind collapsed parents and the search looks broken.\n expanded={query ? new Set(shown.flatMap(allValues)) : expanded}\n onToggleExpand={toggleExpand}\n onToggle={toggle}\n disabled={disabled}\n />\n ))\n )}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { TreeSelect }\nexport type { TreeSelectProps }\n"
|
|
22
|
+
"content": "import { useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight, Search } from 'lucide-react'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Input } from '@/components/ui/input'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport { fieldBase, fieldOutline, fieldSize, focusRing, menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A tree in a popover, with checkboxes and real tri-state parents.\n *\n * **The tri-state is the whole problem.** A parent whose children are partly\n * chosen is neither checked nor unchecked, and rendering it as unchecked is a\n * lie people act on — they tick it, and silently replace a careful selection\n * with everything. The indeterminate state is set through the DOM property,\n * because `indeterminate` is not an HTML attribute and cannot be expressed in\n * JSX; `aria-checked=\"mixed\"` carries the same fact to assistive technology.\n *\n * **Checking a branch checks its subtree, and `value` holds only the leaves.**\n * Storing branch ids as well means every consumer has to know whether a parent\n * in the list implies its children, and two consumers will answer differently.\n * Leaves are unambiguous, and parent state is derived on render.\n *\n * Searching filters to matching nodes **and keeps their ancestors**, because a\n * result with its path cut off is unplaceable — you cannot tell which \"General\"\n * you found.\n */\nexport type TreeSelectNode = {\n value: string\n label: ReactNode\n children?: TreeSelectNode[]\n disabled?: boolean\n}\n\ntype TreeSelectProps = Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'> & {\n nodes: TreeSelectNode[]\n /** Selected leaf values. */\n value?: string[]\n defaultValue?: string[]\n onChange?: (value: string[]) => void\n placeholder?: string\n searchable?: boolean\n searchPlaceholder?: string\n /** Values of branches open on first render. */\n defaultExpanded?: string[]\n size?: 'sm' | 'md' | 'lg'\n /** Summarises the selection on the trigger. */\n summary?: (values: string[]) => ReactNode\n emptyLabel?: string\n disabled?: boolean\n invalid?: boolean\n label?: string\n}\n\nconst leavesOf = (node: TreeSelectNode): string[] =>\n node.children?.length ? node.children.flatMap(leavesOf) : [node.value]\n\n/** Every value in a subtree, branches included — used to force-open a search. */\nconst allValues = (node: TreeSelectNode): string[] => [\n node.value,\n ...(node.children ?? []).flatMap(allValues),\n]\n\n/** Keep a node if it matches, or if any descendant does. */\nfunction filterTree(nodes: TreeSelectNode[], needle: string): TreeSelectNode[] {\n if (!needle) return nodes\n const out: TreeSelectNode[] = []\n for (const node of nodes) {\n const label = typeof node.label === 'string' ? node.label : node.value\n const kept = node.children ? filterTree(node.children, needle) : undefined\n if (label.toLowerCase().includes(needle) || kept?.length) {\n out.push({ ...node, children: kept?.length ? kept : node.children && [] })\n }\n }\n return out\n}\n\nfunction Row({\n node,\n depth,\n chosen,\n expanded,\n onToggleExpand,\n onToggle,\n disabled,\n}: {\n node: TreeSelectNode\n depth: number\n chosen: Set<string>\n expanded: Set<string>\n onToggleExpand: (value: string) => void\n onToggle: (node: TreeSelectNode, next: boolean) => void\n disabled?: boolean\n}) {\n const leaves = leavesOf(node)\n const picked = leaves.filter((leaf) => chosen.has(leaf)).length\n const all = picked === leaves.length && leaves.length > 0\n const some = picked > 0 && !all\n const branch = Boolean(node.children?.length)\n const open = expanded.has(node.value)\n\n return (\n <li role=\"treeitem\" aria-expanded={branch ? open : undefined} aria-selected={all}>\n <div\n className={cn('flex items-center gap-1.5 py-1 pe-2', radius.xs, 'hover:bg-muted')}\n style={{ paddingInlineStart: `${depth * 16 + 6}px` }}\n >\n {branch ? (\n <button\n type=\"button\"\n aria-label={open ? 'Collapse' : 'Expand'}\n onClick={() => onToggleExpand(node.value)}\n className={cn('text-muted-foreground shrink-0', radius.xs, focusRing)}\n >\n {open ? (\n <ChevronDown className=\"size-3.5\" />\n ) : (\n <ChevronRight className=\"size-3.5 rtl:rotate-180\" />\n )}\n </button>\n ) : (\n <span aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n )}\n\n <label className=\"flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-sm\">\n {/* The kit's own Checkbox, which already owns the tri-state: it sets\n the `indeterminate` property (never an attribute, so it cannot be\n written in JSX) and reports `aria-checked=\"mixed\"`. */}\n <Checkbox\n size=\"sm\"\n checked={all}\n indeterminate={some}\n disabled={node.disabled || disabled}\n onChange={(event) => onToggle(node, event.target.checked)}\n />\n <span className=\"min-w-0 flex-1 truncate\">{node.label}</span>\n {branch && (\n <span className=\"text-muted-foreground shrink-0 text-[11px] tabular-nums\">\n {picked}/{leaves.length}\n </span>\n )}\n </label>\n </div>\n\n {branch && open && (\n <ul role=\"group\" className=\"list-none\">\n {node.children?.map((child) => (\n <Row\n key={child.value}\n node={child}\n depth={depth + 1}\n chosen={chosen}\n expanded={expanded}\n onToggleExpand={onToggleExpand}\n onToggle={onToggle}\n disabled={disabled}\n />\n ))}\n </ul>\n )}\n </li>\n )\n}\n\nfunction TreeSelect({\n nodes,\n value,\n defaultValue = [],\n onChange,\n placeholder = 'Select…',\n searchable = true,\n searchPlaceholder = 'Search',\n defaultExpanded = [],\n size = 'md',\n summary,\n emptyLabel = 'Nothing matches.',\n disabled,\n invalid,\n label,\n className,\n ...props\n}: TreeSelectProps) {\n const anchorRef = useRef<HTMLButtonElement>(null)\n const floatingRef = useRef<HTMLDivElement>(null)\n const [open, setOpen] = useState(false)\n const [query, setQuery] = useState('')\n const [internal, setInternal] = useState<string[]>(defaultValue)\n const [expanded, setExpanded] = useState<Set<string>>(new Set(defaultExpanded))\n\n const selected = value ?? internal\n const chosen = useMemo(() => new Set(selected), [selected])\n\n const { style } = usePopper({\n open,\n anchorRef,\n floatingRef,\n side: 'bottom',\n align: 'start',\n matchAnchorWidth: true,\n })\n useDismissable({ open, onDismiss: () => setOpen(false), refs: [anchorRef, floatingRef] })\n\n const shown = useMemo(() => filterTree(nodes, query.trim().toLowerCase()), [nodes, query])\n\n const commit = (next: string[]) => {\n if (value === undefined) setInternal(next)\n onChange?.(next)\n }\n\n const toggle = (node: TreeSelectNode, on: boolean) => {\n const leaves = leavesOf(node)\n const next = new Set(selected)\n for (const leaf of leaves) {\n if (on) next.add(leaf)\n else next.delete(leaf)\n }\n commit([...next])\n }\n\n const toggleExpand = (target: string) => {\n const next = new Set(expanded)\n if (next.has(target)) next.delete(target)\n else next.add(target)\n setExpanded(next)\n }\n\n return (\n <div data-slot=\"tree-select\" className={cn('relative inline-block', className)} {...props}>\n <button\n ref={anchorRef}\n type=\"button\"\n disabled={disabled}\n aria-haspopup=\"tree\"\n aria-expanded={open}\n aria-label={label}\n aria-invalid={invalid || undefined}\n onClick={() => setOpen((current) => !current)}\n className={cn(\n fieldBase, fieldOutline,\n fieldSize[size],\n 'flex w-full items-center justify-between gap-2 text-start',\n invalid && 'border-[var(--destructive)]',\n )}\n >\n <span\n className={cn('min-w-0 flex-1 truncate', selected.length === 0 && 'text-muted-foreground')}\n >\n {selected.length === 0\n ? placeholder\n : (summary?.(selected) ?? `${selected.length} selected`)}\n </span>\n <ChevronDown aria-hidden=\"true\" className=\"text-muted-foreground size-3.5 shrink-0\" />\n </button>\n\n {open && (\n <div\n ref={floatingRef}\n style={style}\n className={cn(menuSurface, radius.surface, 'flex max-h-80 flex-col overflow-hidden p-0')}\n >\n {searchable && (\n <div className=\"border-border border-b p-2\">\n <Input\n size=\"sm\"\n autoFocus\n icon={<Search />}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={searchPlaceholder}\n aria-label={searchPlaceholder}\n />\n </div>\n )}\n\n <ul role=\"tree\" aria-label={label} className=\"min-h-0 flex-1 list-none overflow-y-auto p-1\">\n {shown.length === 0 ? (\n <li className=\"text-muted-foreground p-2 text-xs\">{emptyLabel}</li>\n ) : (\n shown.map((node) => (\n <Row\n key={node.value}\n node={node}\n depth={0}\n chosen={chosen}\n // While searching every branch is open, or the matches are\n // hidden behind collapsed parents and the search looks broken.\n expanded={query ? new Set(shown.flatMap(allValues)) : expanded}\n onToggleExpand={toggleExpand}\n onToggle={toggle}\n disabled={disabled}\n />\n ))\n )}\n </ul>\n </div>\n )}\n </div>\n )\n}\n\nexport { TreeSelect }\nexport type { TreeSelectProps }\n"
|
|
21
23
|
}
|
|
22
24
|
]
|
|
23
25
|
}
|