From cf1bd30390d3e4c8b8df71bf060487db90627f2d Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 24 Sep 2025 15:52:53 +0000 Subject: [PATCH 01/15] add: react package carousel and popover --- frontend/package-lock.json | 243 ++++++++++++++++++++++++ frontend/package.json | 4 + frontend/src/components/ui/carousel.tsx | 239 +++++++++++++++++++++++ frontend/src/components/ui/popover.tsx | 46 +++++ 4 files changed, 532 insertions(+) create mode 100644 frontend/src/components/ui/carousel.tsx create mode 100644 frontend/src/components/ui/popover.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e72a44d..8a07eece 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,10 @@ "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -22,6 +25,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.525.0", "react": "^19.1.0", "react-day-picker": "^9.8.0", @@ -2166,6 +2170,15 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -2207,6 +2220,147 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", @@ -2341,6 +2495,67 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", @@ -4938,6 +5153,34 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index af24ecfb..47f65e8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,10 @@ "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -32,6 +35,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.525.0", "react": "^19.1.0", "react-day-picker": "^9.8.0", diff --git a/frontend/src/components/ui/carousel.tsx b/frontend/src/components/ui/carousel.tsx new file mode 100644 index 00000000..71cff4c9 --- /dev/null +++ b/frontend/src/components/ui/carousel.tsx @@ -0,0 +1,239 @@ +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) return + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) return + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel() + + return ( +
+ ) +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 00000000..6d51b6ce --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } From 07311e2c9091205ecaa0c98ed4d3bfbbc081c29f Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 24 Sep 2025 15:54:03 +0000 Subject: [PATCH 02/15] feature: create time-picker --- .../src/components/ui/time-picker.stories.tsx | 37 ++++ frontend/src/components/ui/time-picker.tsx | 162 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 frontend/src/components/ui/time-picker.stories.tsx create mode 100644 frontend/src/components/ui/time-picker.tsx diff --git a/frontend/src/components/ui/time-picker.stories.tsx b/frontend/src/components/ui/time-picker.stories.tsx new file mode 100644 index 00000000..e85a03de --- /dev/null +++ b/frontend/src/components/ui/time-picker.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { TimePicker } from './time-picker'; + +const meta: Meta = { + title: 'UI/TimePicker', + component: TimePicker, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + placeholder: { + control: 'text', + }, + disabled: { + control: 'boolean', + }, + className: { + control: 'text', + }, + }, + args: { + placeholder: 'テキストを入力してください...', + disabled: false, + }, +}; + +// biome-ignore lint/style/noDefaultExport: Storybook requires default export for meta +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'デフォルトのテキストエリア', + }, +}; diff --git a/frontend/src/components/ui/time-picker.tsx b/frontend/src/components/ui/time-picker.tsx new file mode 100644 index 00000000..9688625d --- /dev/null +++ b/frontend/src/components/ui/time-picker.tsx @@ -0,0 +1,162 @@ +'use client'; + +import * as React from 'react'; +import { ClockIcon } from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { CarouselApi } from '@/components/ui/carousel'; +import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel'; + +function TimePicker() { + const [date, setDate] = React.useState(new Date()); + const [isOpen, setIsOpen] = React.useState(false); + const [hourApi, setHourApi] = React.useState(); + const [minuteApi, setMinuteApi] = React.useState(); + + const hours = Array.from({ length: 12 }, (_, i) => i + 1); + const minutes = Array.from({ length: 60 }, (_, i) => i); + + const handleTimeChange = React.useCallback((type: 'hour' | 'minute' | 'ampm', value: string) => { + setDate(prevDate => { + const newDate = new Date(prevDate); + if (type === 'hour') { + const currentHours = newDate.getHours(); + const newHour = parseInt(value); + newDate.setHours((newHour % 12) + (currentHours >= 12 ? 12 : 0)); + } else if (type === 'minute') { + newDate.setMinutes(parseInt(value)); + } else if (type === 'ampm') { + const currentHours = newDate.getHours(); + if (value === 'PM' && currentHours < 12) { + newDate.setHours(currentHours + 12); + } else if (value === 'AM' && currentHours >= 12) { + newDate.setHours(currentHours - 12); + } + } + return newDate; + }); + }, []); + + React.useEffect(() => { + if (!hourApi) return; + const handleSelect = () => { + const selectedHour = hours[hourApi.selectedScrollSnap()]; + handleTimeChange('hour', selectedHour.toString()); + }; + hourApi.on('select', handleSelect); + return () => { + hourApi.off('select', handleSelect); + }; + }, [hourApi, hours, handleTimeChange]); + + React.useEffect(() => { + if (!minuteApi) return; + const handleSelect = () => { + const selectedMinute = minutes[minuteApi.selectedScrollSnap()]; + handleTimeChange('minute', selectedMinute.toString()); + }; + minuteApi.on('select', handleSelect); + return () => { + minuteApi.off('select', handleSelect); + }; + }, [minuteApi, minutes, handleTimeChange]); + + React.useEffect(() => { + if (hourApi) { + const currentHour = date.getHours(); + const hourValue = currentHour % 12 === 0 ? 12 : currentHour % 12; + const hourIndex = hours.indexOf(hourValue); + hourApi.scrollTo(hourIndex); + } + if (minuteApi) { + minuteApi.scrollTo(date.getMinutes()); + } + }, [hourApi, minuteApi, date, hours]); + + return ( + + + + + +
+ { + if (e.deltaY < 0) { + hourApi?.scrollPrev(); + } else { + hourApi?.scrollNext(); + } + }} + > + + {hours.map(hour => ( + +
{hour}
+
+ ))} +
+
+ : + { + if (e.deltaY < 0) { + minuteApi?.scrollPrev(); + } else { + minuteApi?.scrollNext(); + } + }} + > + + {minutes.map(minute => ( + +
+ {minute.toString().padStart(2, '0')} +
+
+ ))} +
+
+
+ +
+
+
+
+ ); +} + +export { TimePicker }; From 177970e5f87d319b9cdb43c9f400ab3e8f2bfd75 Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 24 Sep 2025 15:54:27 +0000 Subject: [PATCH 03/15] feature: create clock-time-picker --- .../ui/clock-time-picker.stories.tsx | 17 ++ .../src/components/ui/clock-time-picker.tsx | 223 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 frontend/src/components/ui/clock-time-picker.stories.tsx create mode 100644 frontend/src/components/ui/clock-time-picker.tsx diff --git a/frontend/src/components/ui/clock-time-picker.stories.tsx b/frontend/src/components/ui/clock-time-picker.stories.tsx new file mode 100644 index 00000000..dead44ab --- /dev/null +++ b/frontend/src/components/ui/clock-time-picker.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { ClockTimePicker } from './clock-time-picker'; + +const meta: Meta = { + title: 'UI/ClockTimePicker', + component: ClockTimePicker, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/ui/clock-time-picker.tsx b/frontend/src/components/ui/clock-time-picker.tsx new file mode 100644 index 00000000..08766e0b --- /dev/null +++ b/frontend/src/components/ui/clock-time-picker.tsx @@ -0,0 +1,223 @@ +'use client'; + +import * as React from 'react'; +import { ClockIcon } from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +const CLOCK_SIZE = 200; +const CLOCK_CENTER = CLOCK_SIZE / 2; +const NUMBER_RADIUS = CLOCK_SIZE / 2 - 20; + +function ClockTimePicker() { + const [date, setDate] = React.useState(new Date()); + const [isOpen, setIsOpen] = React.useState(false); + const [mode, setMode] = React.useState<'hour' | 'minute'>('hour'); + const clockRef = React.useRef(null); + const [isDragging, setIsDragging] = React.useState(false); + const dragStartTime = React.useRef(0); + const hasHourShiftedRef = React.useRef(false); + + const handleTimeUpdate = (newTime: { hour?: number; minute?: number }) => { + setDate(prevDate => { + const newDate = new Date(prevDate); + const currentHours = newDate.getHours(); + let hourWasActuallyChanged = false; + + if (newTime.hour !== undefined) { + let targetHour = newTime.hour; + // 12時間制の値を24時間制に変換 + if (targetHour === 12) { + targetHour = currentHours >= 12 ? 12 : 0; // 現在がPMなら12時PM、AMなら12時AM(0時) + } else { + // 1-11時の場合、現在のAM/PMを維持 + targetHour = currentHours >= 12 ? targetHour + 12 : targetHour; + } + + if (currentHours !== targetHour) { + hourWasActuallyChanged = true; + } + newDate.setHours(targetHour); + } + if (newTime.minute !== undefined) { + newDate.setMinutes(newTime.minute); + } + + if (hourWasActuallyChanged) { + hasHourShiftedRef.current = true; + } + return newDate; + }); + }; + + const handleInteraction = (e: React.MouseEvent) => { + if (!clockRef.current) return; + const rect = clockRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left - CLOCK_CENTER; + const y = e.clientY - rect.top - CLOCK_CENTER; + + let angle = Math.atan2(y, x) * (180 / Math.PI) + 90; + if (angle < 0) angle += 360; + + if (mode === 'hour') { + const hour = Math.round(angle / 30) % 12 || 12; + handleTimeUpdate({ hour }); + } else if (mode === 'minute') { + const minute = Math.round(angle / 6) % 60; + handleTimeUpdate({ minute }); + } + }; + + const handleChangeAMPM = (v: string) => { + setDate(prevDate => { + const newDate = new Date(prevDate); + const currentHours = newDate.getHours(); + if (v === 'PM' && currentHours < 12) { + newDate.setHours(currentHours + 12); + } else if (v === 'AM' && currentHours >= 12) { + newDate.setHours(currentHours - 12); + } + return newDate; + }); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + dragStartTime.current = Date.now(); // ドラッグ開始時刻を記録 + hasHourShiftedRef.current = false; + handleInteraction(e); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging) return; + handleInteraction(e); + }; + + const handleMouseUp = () => { + setIsDragging(false); + if (mode === 'hour' && hasHourShiftedRef.current) { + setMode('minute'); + } + hasHourShiftedRef.current = false; + }; + + const hours = Array.from({ length: 12 }, (_, i) => i + 1); + const minutes = Array.from({ length: 12 }, (_, i) => i * 5); + + return ( + + + + + +
+ {/* Header: Selected time and mode toggle */} +
+ + : + + {/* AM/PM toggle */} +
+ +
+
+ + {/* Clock face */} +
+ {/* Numbers will be placed here */} + {(mode === 'hour' ? hours : minutes).map(value => { + const angle = (mode === 'hour' ? value / 12 : value / 60) * 360 - 90; // -90 to start from top + const x = CLOCK_CENTER + NUMBER_RADIUS * Math.cos(angle * (Math.PI / 180)); + const y = CLOCK_CENTER + NUMBER_RADIUS * Math.sin(angle * (Math.PI / 180)); + const isSelected = + mode === 'hour' + ? value === (date.getHours() % 12 === 0 ? 12 : date.getHours() % 12) + : value === date.getMinutes(); + + return ( +
+ {mode === 'minute' ? value.toString().padStart(2, '0') : value} +
+ ); + })} + + {/* Hour hand */} +
+ + {/* Minute hand */} +
+ + {/* Center dot */} +
+
+
+ + + ); +} + +export { ClockTimePicker }; From f5af75336de6c10393b1180a08e2999adbc86c6f Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Sun, 1 Feb 2026 14:36:52 +0000 Subject: [PATCH 04/15] lint and format --- biome.json | 1 + frontend/src/components/ui/carousel.tsx | 216 ++++++++---------- .../ui/clock-time-picker.stories.tsx | 1 + .../src/components/ui/clock-time-picker.tsx | 27 ++- frontend/src/components/ui/popover.tsx | 34 ++- frontend/src/components/ui/time-picker.tsx | 18 +- 6 files changed, 135 insertions(+), 162 deletions(-) diff --git a/biome.json b/biome.json index b99a15d6..ae0081ec 100644 --- a/biome.json +++ b/biome.json @@ -34,6 +34,7 @@ "enabled": true, "rules": { "recommended": true, + "a11y": "off", "nursery": { "useSortedClasses": { "level": "error", diff --git a/frontend/src/components/ui/carousel.tsx b/frontend/src/components/ui/carousel.tsx index 71cff4c9..f9e0bc24 100644 --- a/frontend/src/components/ui/carousel.tsx +++ b/frontend/src/components/ui/carousel.tsx @@ -1,106 +1,103 @@ -import * as React from "react" -import useEmblaCarousel, { - type UseEmblaCarouselType, -} from "embla-carousel-react" -import { ArrowLeft, ArrowRight } from "lucide-react" +import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -type CarouselApi = UseEmblaCarouselType[1] -type UseCarouselParameters = Parameters -type CarouselOptions = UseCarouselParameters[0] -type CarouselPlugin = UseCarouselParameters[1] +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; type CarouselProps = { - opts?: CarouselOptions - plugins?: CarouselPlugin - orientation?: "horizontal" | "vertical" - setApi?: (api: CarouselApi) => void -} + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: 'horizontal' | 'vertical'; + setApi?: (api: CarouselApi) => void; +}; type CarouselContextProps = { - carouselRef: ReturnType[0] - api: ReturnType[1] - scrollPrev: () => void - scrollNext: () => void - canScrollPrev: boolean - canScrollNext: boolean -} & CarouselProps + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; -const CarouselContext = React.createContext(null) +const CarouselContext = React.createContext(null); function useCarousel() { - const context = React.useContext(CarouselContext) + const context = React.useContext(CarouselContext); if (!context) { - throw new Error("useCarousel must be used within a ") + throw new Error('useCarousel must be used within a '); } - return context + return context; } function Carousel({ - orientation = "horizontal", + orientation = 'horizontal', opts, setApi, plugins, className, children, ...props -}: React.ComponentProps<"div"> & CarouselProps) { +}: React.ComponentProps<'div'> & CarouselProps) { const [carouselRef, api] = useEmblaCarousel( { ...opts, - axis: orientation === "horizontal" ? "x" : "y", + axis: orientation === 'horizontal' ? 'x' : 'y', }, plugins - ) - const [canScrollPrev, setCanScrollPrev] = React.useState(false) - const [canScrollNext, setCanScrollNext] = React.useState(false) + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) return - setCanScrollPrev(api.canScrollPrev()) - setCanScrollNext(api.canScrollNext()) - }, []) + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); const scrollPrev = React.useCallback(() => { - api?.scrollPrev() - }, [api]) + api?.scrollPrev(); + }, [api]); const scrollNext = React.useCallback(() => { - api?.scrollNext() - }, [api]) + api?.scrollNext(); + }, [api]); const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (event.key === "ArrowLeft") { - event.preventDefault() - scrollPrev() - } else if (event.key === "ArrowRight") { - event.preventDefault() - scrollNext() + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollNext(); } }, [scrollPrev, scrollNext] - ) + ); React.useEffect(() => { - if (!api || !setApi) return - setApi(api) - }, [api, setApi]) + if (!(api && setApi)) return; + setApi(api); + }, [api, setApi]); React.useEffect(() => { - if (!api) return - onSelect(api) - api.on("reInit", onSelect) - api.on("select", onSelect) + if (!api) return; + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); return () => { - api?.off("select", onSelect) - } - }, [api, onSelect]) + api?.off('select', onSelect); + }; + }, [api, onSelect]); return (
{children}
- ) + ); } -function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { - const { carouselRef, orientation } = useCarousel() +function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) { + const { carouselRef, orientation } = useCarousel(); return ( -
-
+
+
- ) + ); } -function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { - const { orientation } = useCarousel() +function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) { + const { orientation } = useCarousel(); return (
- ) + ); } function CarouselPrevious({ className, - variant = "outline", - size = "icon", + variant = 'outline', + size = 'icon', ...props }: React.ComponentProps) { - const { orientation, scrollPrev, canScrollPrev } = useCarousel() + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); return ( - ) + ); } function CarouselNext({ className, - variant = "outline", - size = "icon", + variant = 'outline', + size = 'icon', ...props }: React.ComponentProps) { - const { orientation, scrollNext, canScrollNext } = useCarousel() + const { orientation, scrollNext, canScrollNext } = useCarousel(); return ( - ) + ); } -export { - type CarouselApi, - Carousel, - CarouselContent, - CarouselItem, - CarouselPrevious, - CarouselNext, -} +export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }; diff --git a/frontend/src/components/ui/clock-time-picker.stories.tsx b/frontend/src/components/ui/clock-time-picker.stories.tsx index dead44ab..8ff320f9 100644 --- a/frontend/src/components/ui/clock-time-picker.stories.tsx +++ b/frontend/src/components/ui/clock-time-picker.stories.tsx @@ -11,6 +11,7 @@ const meta: Meta = { tags: ['autodocs'], }; +// biome-ignore lint/style/noDefaultExport: Storybook requires default export for meta export default meta; type Story = StoryObj; diff --git a/frontend/src/components/ui/clock-time-picker.tsx b/frontend/src/components/ui/clock-time-picker.tsx index 08766e0b..55cc9736 100644 --- a/frontend/src/components/ui/clock-time-picker.tsx +++ b/frontend/src/components/ui/clock-time-picker.tsx @@ -1,11 +1,10 @@ 'use client'; -import * as React from 'react'; import { ClockIcon } from '@radix-ui/react-icons'; - -import { cn } from '@/lib/utils'; +import * as React from 'react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; const CLOCK_SIZE = 200; const CLOCK_CENTER = CLOCK_SIZE / 2; @@ -121,10 +120,10 @@ function ClockTimePicker() { )} - +
{/* Header: Selected time and mode toggle */} -
+
@@ -133,7 +132,7 @@ function ClockTimePicker() { {date.getMinutes().toString().padStart(2, '0')} {/* AM/PM toggle */} -
+
@@ -142,8 +141,14 @@ function ClockTimePicker() { {/* Clock face */}
) { - return +function Popover({ ...props }: React.ComponentProps) { + return ; } -function PopoverTrigger({ - ...props -}: React.ComponentProps) { - return +function PopoverTrigger({ ...props }: React.ComponentProps) { + return ; } function PopoverContent({ className, - align = "center", + align = 'center', sideOffset = 4, ...props }: React.ComponentProps) { return ( - ) + ); } -function PopoverAnchor({ - ...props -}: React.ComponentProps) { - return +function PopoverAnchor({ ...props }: React.ComponentProps) { + return ; } -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/frontend/src/components/ui/time-picker.tsx b/frontend/src/components/ui/time-picker.tsx index 9688625d..280798b0 100644 --- a/frontend/src/components/ui/time-picker.tsx +++ b/frontend/src/components/ui/time-picker.tsx @@ -1,14 +1,12 @@ 'use client'; -import * as React from 'react'; import { ClockIcon } from '@radix-ui/react-icons'; - -import { cn } from '@/lib/utils'; +import * as React from 'react'; import { Button } from '@/components/ui/button'; - -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { CarouselApi } from '@/components/ui/carousel'; import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; function TimePicker() { const [date, setDate] = React.useState(new Date()); @@ -91,7 +89,7 @@ function TimePicker() { )} - +
{hours.map(hour => ( -
{hour}
+
{hour}
))}
- : + : {minutes.map(minute => ( -
+
{minute.toString().padStart(2, '0')}
))} -
+
From 78112c494f7ffaf6fd72dc92cf4e0aae5bbcd832 Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 24 Sep 2025 15:52:53 +0000 Subject: [PATCH 05/15] add: react package carousel and popover --- frontend/package-lock.json | 243 ++++++++++++++++++++++++ frontend/package.json | 4 + frontend/src/components/ui/carousel.tsx | 239 +++++++++++++++++++++++ frontend/src/components/ui/popover.tsx | 46 +++++ 4 files changed, 532 insertions(+) create mode 100644 frontend/src/components/ui/carousel.tsx create mode 100644 frontend/src/components/ui/popover.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 50be5a8e..d5f10c12 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,7 +17,10 @@ "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -32,6 +35,7 @@ "dayjs": "^1.11.18", "install": "^0.13.0", "lodash-es": "^4.17.21", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.525.0", "npm": "^11.6.0", "react": "^19.1.0", @@ -2074,6 +2078,15 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -2115,6 +2128,147 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", @@ -2249,6 +2403,67 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", @@ -4677,6 +4892,34 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 868873f4..353a4bfc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,7 +28,10 @@ "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -43,6 +46,7 @@ "dayjs": "^1.11.18", "install": "^0.13.0", "lodash-es": "^4.17.21", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.525.0", "npm": "^11.6.0", "react": "^19.1.0", diff --git a/frontend/src/components/ui/carousel.tsx b/frontend/src/components/ui/carousel.tsx new file mode 100644 index 00000000..71cff4c9 --- /dev/null +++ b/frontend/src/components/ui/carousel.tsx @@ -0,0 +1,239 @@ +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) return + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) return + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel() + + return ( +
+ ) +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 00000000..6d51b6ce --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } From b9d760413b95f231cca365218d5b988136391e26 Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 24 Sep 2025 15:54:03 +0000 Subject: [PATCH 06/15] feature: create time-picker --- .../src/components/ui/time-picker.stories.tsx | 37 ++++ frontend/src/components/ui/time-picker.tsx | 162 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 frontend/src/components/ui/time-picker.stories.tsx create mode 100644 frontend/src/components/ui/time-picker.tsx diff --git a/frontend/src/components/ui/time-picker.stories.tsx b/frontend/src/components/ui/time-picker.stories.tsx new file mode 100644 index 00000000..e85a03de --- /dev/null +++ b/frontend/src/components/ui/time-picker.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { TimePicker } from './time-picker'; + +const meta: Meta = { + title: 'UI/TimePicker', + component: TimePicker, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + placeholder: { + control: 'text', + }, + disabled: { + control: 'boolean', + }, + className: { + control: 'text', + }, + }, + args: { + placeholder: 'テキストを入力してください...', + disabled: false, + }, +}; + +// biome-ignore lint/style/noDefaultExport: Storybook requires default export for meta +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'デフォルトのテキストエリア', + }, +}; diff --git a/frontend/src/components/ui/time-picker.tsx b/frontend/src/components/ui/time-picker.tsx new file mode 100644 index 00000000..9688625d --- /dev/null +++ b/frontend/src/components/ui/time-picker.tsx @@ -0,0 +1,162 @@ +'use client'; + +import * as React from 'react'; +import { ClockIcon } from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { CarouselApi } from '@/components/ui/carousel'; +import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel'; + +function TimePicker() { + const [date, setDate] = React.useState(new Date()); + const [isOpen, setIsOpen] = React.useState(false); + const [hourApi, setHourApi] = React.useState(); + const [minuteApi, setMinuteApi] = React.useState(); + + const hours = Array.from({ length: 12 }, (_, i) => i + 1); + const minutes = Array.from({ length: 60 }, (_, i) => i); + + const handleTimeChange = React.useCallback((type: 'hour' | 'minute' | 'ampm', value: string) => { + setDate(prevDate => { + const newDate = new Date(prevDate); + if (type === 'hour') { + const currentHours = newDate.getHours(); + const newHour = parseInt(value); + newDate.setHours((newHour % 12) + (currentHours >= 12 ? 12 : 0)); + } else if (type === 'minute') { + newDate.setMinutes(parseInt(value)); + } else if (type === 'ampm') { + const currentHours = newDate.getHours(); + if (value === 'PM' && currentHours < 12) { + newDate.setHours(currentHours + 12); + } else if (value === 'AM' && currentHours >= 12) { + newDate.setHours(currentHours - 12); + } + } + return newDate; + }); + }, []); + + React.useEffect(() => { + if (!hourApi) return; + const handleSelect = () => { + const selectedHour = hours[hourApi.selectedScrollSnap()]; + handleTimeChange('hour', selectedHour.toString()); + }; + hourApi.on('select', handleSelect); + return () => { + hourApi.off('select', handleSelect); + }; + }, [hourApi, hours, handleTimeChange]); + + React.useEffect(() => { + if (!minuteApi) return; + const handleSelect = () => { + const selectedMinute = minutes[minuteApi.selectedScrollSnap()]; + handleTimeChange('minute', selectedMinute.toString()); + }; + minuteApi.on('select', handleSelect); + return () => { + minuteApi.off('select', handleSelect); + }; + }, [minuteApi, minutes, handleTimeChange]); + + React.useEffect(() => { + if (hourApi) { + const currentHour = date.getHours(); + const hourValue = currentHour % 12 === 0 ? 12 : currentHour % 12; + const hourIndex = hours.indexOf(hourValue); + hourApi.scrollTo(hourIndex); + } + if (minuteApi) { + minuteApi.scrollTo(date.getMinutes()); + } + }, [hourApi, minuteApi, date, hours]); + + return ( + + + + + +
+ { + if (e.deltaY < 0) { + hourApi?.scrollPrev(); + } else { + hourApi?.scrollNext(); + } + }} + > + + {hours.map(hour => ( + +
{hour}
+
+ ))} +
+
+ : + { + if (e.deltaY < 0) { + minuteApi?.scrollPrev(); + } else { + minuteApi?.scrollNext(); + } + }} + > + + {minutes.map(minute => ( + +
+ {minute.toString().padStart(2, '0')} +
+
+ ))} +
+
+
+ +
+
+
+
+ ); +} + +export { TimePicker }; From 54a5bf6999eaf8fb5f0a656cff1a5b8e832f3911 Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 24 Sep 2025 15:54:27 +0000 Subject: [PATCH 07/15] feature: create clock-time-picker --- .../ui/clock-time-picker.stories.tsx | 17 ++ .../src/components/ui/clock-time-picker.tsx | 223 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 frontend/src/components/ui/clock-time-picker.stories.tsx create mode 100644 frontend/src/components/ui/clock-time-picker.tsx diff --git a/frontend/src/components/ui/clock-time-picker.stories.tsx b/frontend/src/components/ui/clock-time-picker.stories.tsx new file mode 100644 index 00000000..dead44ab --- /dev/null +++ b/frontend/src/components/ui/clock-time-picker.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { ClockTimePicker } from './clock-time-picker'; + +const meta: Meta = { + title: 'UI/ClockTimePicker', + component: ClockTimePicker, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/ui/clock-time-picker.tsx b/frontend/src/components/ui/clock-time-picker.tsx new file mode 100644 index 00000000..08766e0b --- /dev/null +++ b/frontend/src/components/ui/clock-time-picker.tsx @@ -0,0 +1,223 @@ +'use client'; + +import * as React from 'react'; +import { ClockIcon } from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +const CLOCK_SIZE = 200; +const CLOCK_CENTER = CLOCK_SIZE / 2; +const NUMBER_RADIUS = CLOCK_SIZE / 2 - 20; + +function ClockTimePicker() { + const [date, setDate] = React.useState(new Date()); + const [isOpen, setIsOpen] = React.useState(false); + const [mode, setMode] = React.useState<'hour' | 'minute'>('hour'); + const clockRef = React.useRef(null); + const [isDragging, setIsDragging] = React.useState(false); + const dragStartTime = React.useRef(0); + const hasHourShiftedRef = React.useRef(false); + + const handleTimeUpdate = (newTime: { hour?: number; minute?: number }) => { + setDate(prevDate => { + const newDate = new Date(prevDate); + const currentHours = newDate.getHours(); + let hourWasActuallyChanged = false; + + if (newTime.hour !== undefined) { + let targetHour = newTime.hour; + // 12時間制の値を24時間制に変換 + if (targetHour === 12) { + targetHour = currentHours >= 12 ? 12 : 0; // 現在がPMなら12時PM、AMなら12時AM(0時) + } else { + // 1-11時の場合、現在のAM/PMを維持 + targetHour = currentHours >= 12 ? targetHour + 12 : targetHour; + } + + if (currentHours !== targetHour) { + hourWasActuallyChanged = true; + } + newDate.setHours(targetHour); + } + if (newTime.minute !== undefined) { + newDate.setMinutes(newTime.minute); + } + + if (hourWasActuallyChanged) { + hasHourShiftedRef.current = true; + } + return newDate; + }); + }; + + const handleInteraction = (e: React.MouseEvent) => { + if (!clockRef.current) return; + const rect = clockRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left - CLOCK_CENTER; + const y = e.clientY - rect.top - CLOCK_CENTER; + + let angle = Math.atan2(y, x) * (180 / Math.PI) + 90; + if (angle < 0) angle += 360; + + if (mode === 'hour') { + const hour = Math.round(angle / 30) % 12 || 12; + handleTimeUpdate({ hour }); + } else if (mode === 'minute') { + const minute = Math.round(angle / 6) % 60; + handleTimeUpdate({ minute }); + } + }; + + const handleChangeAMPM = (v: string) => { + setDate(prevDate => { + const newDate = new Date(prevDate); + const currentHours = newDate.getHours(); + if (v === 'PM' && currentHours < 12) { + newDate.setHours(currentHours + 12); + } else if (v === 'AM' && currentHours >= 12) { + newDate.setHours(currentHours - 12); + } + return newDate; + }); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + dragStartTime.current = Date.now(); // ドラッグ開始時刻を記録 + hasHourShiftedRef.current = false; + handleInteraction(e); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging) return; + handleInteraction(e); + }; + + const handleMouseUp = () => { + setIsDragging(false); + if (mode === 'hour' && hasHourShiftedRef.current) { + setMode('minute'); + } + hasHourShiftedRef.current = false; + }; + + const hours = Array.from({ length: 12 }, (_, i) => i + 1); + const minutes = Array.from({ length: 12 }, (_, i) => i * 5); + + return ( + + + + + +
+ {/* Header: Selected time and mode toggle */} +
+ + : + + {/* AM/PM toggle */} +
+ +
+
+ + {/* Clock face */} +
+ {/* Numbers will be placed here */} + {(mode === 'hour' ? hours : minutes).map(value => { + const angle = (mode === 'hour' ? value / 12 : value / 60) * 360 - 90; // -90 to start from top + const x = CLOCK_CENTER + NUMBER_RADIUS * Math.cos(angle * (Math.PI / 180)); + const y = CLOCK_CENTER + NUMBER_RADIUS * Math.sin(angle * (Math.PI / 180)); + const isSelected = + mode === 'hour' + ? value === (date.getHours() % 12 === 0 ? 12 : date.getHours() % 12) + : value === date.getMinutes(); + + return ( +
+ {mode === 'minute' ? value.toString().padStart(2, '0') : value} +
+ ); + })} + + {/* Hour hand */} +
+ + {/* Minute hand */} +
+ + {/* Center dot */} +
+
+
+ + + ); +} + +export { ClockTimePicker }; From c2fbe73681da09bb08da01273c7c014356725567 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Sun, 1 Feb 2026 14:36:52 +0000 Subject: [PATCH 08/15] lint and format --- biome.json | 1 + frontend/src/components/ui/carousel.tsx | 216 ++++++++---------- .../ui/clock-time-picker.stories.tsx | 1 + .../src/components/ui/clock-time-picker.tsx | 27 ++- frontend/src/components/ui/popover.tsx | 34 ++- frontend/src/components/ui/time-picker.tsx | 18 +- 6 files changed, 135 insertions(+), 162 deletions(-) diff --git a/biome.json b/biome.json index 07a96150..1707289d 100644 --- a/biome.json +++ b/biome.json @@ -34,6 +34,7 @@ "enabled": true, "rules": { "recommended": true, + "a11y": "off", "nursery": { "useSortedClasses": { "level": "error", diff --git a/frontend/src/components/ui/carousel.tsx b/frontend/src/components/ui/carousel.tsx index 71cff4c9..f9e0bc24 100644 --- a/frontend/src/components/ui/carousel.tsx +++ b/frontend/src/components/ui/carousel.tsx @@ -1,106 +1,103 @@ -import * as React from "react" -import useEmblaCarousel, { - type UseEmblaCarouselType, -} from "embla-carousel-react" -import { ArrowLeft, ArrowRight } from "lucide-react" +import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -type CarouselApi = UseEmblaCarouselType[1] -type UseCarouselParameters = Parameters -type CarouselOptions = UseCarouselParameters[0] -type CarouselPlugin = UseCarouselParameters[1] +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; type CarouselProps = { - opts?: CarouselOptions - plugins?: CarouselPlugin - orientation?: "horizontal" | "vertical" - setApi?: (api: CarouselApi) => void -} + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: 'horizontal' | 'vertical'; + setApi?: (api: CarouselApi) => void; +}; type CarouselContextProps = { - carouselRef: ReturnType[0] - api: ReturnType[1] - scrollPrev: () => void - scrollNext: () => void - canScrollPrev: boolean - canScrollNext: boolean -} & CarouselProps + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; -const CarouselContext = React.createContext(null) +const CarouselContext = React.createContext(null); function useCarousel() { - const context = React.useContext(CarouselContext) + const context = React.useContext(CarouselContext); if (!context) { - throw new Error("useCarousel must be used within a ") + throw new Error('useCarousel must be used within a '); } - return context + return context; } function Carousel({ - orientation = "horizontal", + orientation = 'horizontal', opts, setApi, plugins, className, children, ...props -}: React.ComponentProps<"div"> & CarouselProps) { +}: React.ComponentProps<'div'> & CarouselProps) { const [carouselRef, api] = useEmblaCarousel( { ...opts, - axis: orientation === "horizontal" ? "x" : "y", + axis: orientation === 'horizontal' ? 'x' : 'y', }, plugins - ) - const [canScrollPrev, setCanScrollPrev] = React.useState(false) - const [canScrollNext, setCanScrollNext] = React.useState(false) + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) return - setCanScrollPrev(api.canScrollPrev()) - setCanScrollNext(api.canScrollNext()) - }, []) + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); const scrollPrev = React.useCallback(() => { - api?.scrollPrev() - }, [api]) + api?.scrollPrev(); + }, [api]); const scrollNext = React.useCallback(() => { - api?.scrollNext() - }, [api]) + api?.scrollNext(); + }, [api]); const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (event.key === "ArrowLeft") { - event.preventDefault() - scrollPrev() - } else if (event.key === "ArrowRight") { - event.preventDefault() - scrollNext() + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollNext(); } }, [scrollPrev, scrollNext] - ) + ); React.useEffect(() => { - if (!api || !setApi) return - setApi(api) - }, [api, setApi]) + if (!(api && setApi)) return; + setApi(api); + }, [api, setApi]); React.useEffect(() => { - if (!api) return - onSelect(api) - api.on("reInit", onSelect) - api.on("select", onSelect) + if (!api) return; + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); return () => { - api?.off("select", onSelect) - } - }, [api, onSelect]) + api?.off('select', onSelect); + }; + }, [api, onSelect]); return (
{children}
- ) + ); } -function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { - const { carouselRef, orientation } = useCarousel() +function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) { + const { carouselRef, orientation } = useCarousel(); return ( -
-
+
+
- ) + ); } -function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { - const { orientation } = useCarousel() +function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) { + const { orientation } = useCarousel(); return (
- ) + ); } function CarouselPrevious({ className, - variant = "outline", - size = "icon", + variant = 'outline', + size = 'icon', ...props }: React.ComponentProps) { - const { orientation, scrollPrev, canScrollPrev } = useCarousel() + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); return ( - ) + ); } function CarouselNext({ className, - variant = "outline", - size = "icon", + variant = 'outline', + size = 'icon', ...props }: React.ComponentProps) { - const { orientation, scrollNext, canScrollNext } = useCarousel() + const { orientation, scrollNext, canScrollNext } = useCarousel(); return ( - ) + ); } -export { - type CarouselApi, - Carousel, - CarouselContent, - CarouselItem, - CarouselPrevious, - CarouselNext, -} +export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }; diff --git a/frontend/src/components/ui/clock-time-picker.stories.tsx b/frontend/src/components/ui/clock-time-picker.stories.tsx index dead44ab..8ff320f9 100644 --- a/frontend/src/components/ui/clock-time-picker.stories.tsx +++ b/frontend/src/components/ui/clock-time-picker.stories.tsx @@ -11,6 +11,7 @@ const meta: Meta = { tags: ['autodocs'], }; +// biome-ignore lint/style/noDefaultExport: Storybook requires default export for meta export default meta; type Story = StoryObj; diff --git a/frontend/src/components/ui/clock-time-picker.tsx b/frontend/src/components/ui/clock-time-picker.tsx index 08766e0b..55cc9736 100644 --- a/frontend/src/components/ui/clock-time-picker.tsx +++ b/frontend/src/components/ui/clock-time-picker.tsx @@ -1,11 +1,10 @@ 'use client'; -import * as React from 'react'; import { ClockIcon } from '@radix-ui/react-icons'; - -import { cn } from '@/lib/utils'; +import * as React from 'react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; const CLOCK_SIZE = 200; const CLOCK_CENTER = CLOCK_SIZE / 2; @@ -121,10 +120,10 @@ function ClockTimePicker() { )} - +
{/* Header: Selected time and mode toggle */} -
+
@@ -133,7 +132,7 @@ function ClockTimePicker() { {date.getMinutes().toString().padStart(2, '0')} {/* AM/PM toggle */} -
+
@@ -142,8 +141,14 @@ function ClockTimePicker() { {/* Clock face */}
) { - return +function Popover({ ...props }: React.ComponentProps) { + return ; } -function PopoverTrigger({ - ...props -}: React.ComponentProps) { - return +function PopoverTrigger({ ...props }: React.ComponentProps) { + return ; } function PopoverContent({ className, - align = "center", + align = 'center', sideOffset = 4, ...props }: React.ComponentProps) { return ( - ) + ); } -function PopoverAnchor({ - ...props -}: React.ComponentProps) { - return +function PopoverAnchor({ ...props }: React.ComponentProps) { + return ; } -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/frontend/src/components/ui/time-picker.tsx b/frontend/src/components/ui/time-picker.tsx index 9688625d..280798b0 100644 --- a/frontend/src/components/ui/time-picker.tsx +++ b/frontend/src/components/ui/time-picker.tsx @@ -1,14 +1,12 @@ 'use client'; -import * as React from 'react'; import { ClockIcon } from '@radix-ui/react-icons'; - -import { cn } from '@/lib/utils'; +import * as React from 'react'; import { Button } from '@/components/ui/button'; - -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { CarouselApi } from '@/components/ui/carousel'; import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; function TimePicker() { const [date, setDate] = React.useState(new Date()); @@ -91,7 +89,7 @@ function TimePicker() { )} - +
{hours.map(hour => ( -
{hour}
+
{hour}
))}
- : + : {minutes.map(minute => ( -
+
{minute.toString().padStart(2, '0')}
))} -
+
From 32bdc50ad444431f07bd0258da7ab6abedbd9b89 Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Sun, 22 Feb 2026 06:45:10 +0000 Subject: [PATCH 09/15] update: package --- package-lock.json | 679 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 5 + 2 files changed, 683 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 29c44567..d8b478aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,11 @@ "": { "name": "tabishare", "version": "0.0.0", + "dependencies": { + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-popover": "^1.1.15", + "embla-carousel-react": "^8.6.0" + }, "devDependencies": { "@dotenvx/dotenvx": "^1.48.4", "concurrently": "^9.1.0" @@ -51,6 +56,44 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -93,6 +136,444 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -119,6 +600,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -258,6 +751,12 @@ "node": ">= 8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.2.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", @@ -289,6 +788,34 @@ "node": ">=16" } }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -355,6 +882,15 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -517,6 +1053,98 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -537,6 +1165,13 @@ "tslib": "^2.1.0" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -648,9 +1283,51 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", diff --git a/package.json b/package.json index 204231ff..ff1d0076 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,10 @@ "devDependencies": { "@dotenvx/dotenvx": "^1.48.4", "concurrently": "^9.1.0" + }, + "dependencies": { + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-popover": "^1.1.15", + "embla-carousel-react": "^8.6.0" } } From 5826de71fe8fdfa35abb951116525bb3cbe4eca6 Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 25 Mar 2026 15:59:38 +0000 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=E3=82=B9=E3=83=9E=E3=83=9B?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=80=A7=E3=82=92=E5=90=91=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ui/clock-time-picker.tsx | 48 +++++++++---------- frontend/src/components/ui/time-picker.tsx | 20 ++++++-- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/ui/clock-time-picker.tsx b/frontend/src/components/ui/clock-time-picker.tsx index 55cc9736..0a6615e8 100644 --- a/frontend/src/components/ui/clock-time-picker.tsx +++ b/frontend/src/components/ui/clock-time-picker.tsx @@ -16,7 +16,6 @@ function ClockTimePicker() { const [mode, setMode] = React.useState<'hour' | 'minute'>('hour'); const clockRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); - const dragStartTime = React.useRef(0); const hasHourShiftedRef = React.useRef(false); const handleTimeUpdate = (newTime: { hour?: number; minute?: number }) => { @@ -51,7 +50,7 @@ function ClockTimePicker() { }); }; - const handleInteraction = (e: React.MouseEvent) => { + const handleInteraction = (e: React.PointerEvent) => { if (!clockRef.current) return; const rect = clockRef.current.getBoundingClientRect(); const x = e.clientX - rect.left - CLOCK_CENTER; @@ -69,39 +68,41 @@ function ClockTimePicker() { } }; - const handleChangeAMPM = (v: string) => { - setDate(prevDate => { - const newDate = new Date(prevDate); - const currentHours = newDate.getHours(); - if (v === 'PM' && currentHours < 12) { - newDate.setHours(currentHours + 12); - } else if (v === 'AM' && currentHours >= 12) { - newDate.setHours(currentHours - 12); - } - return newDate; - }); - }; - - const handleMouseDown = (e: React.MouseEvent) => { + const handlePointerDown = (e: React.PointerEvent) => { + // ターゲット要素にポインターをロック(枠外に出ても追従) + e.currentTarget.setPointerCapture(e.pointerId); setIsDragging(true); - dragStartTime.current = Date.now(); // ドラッグ開始時刻を記録 hasHourShiftedRef.current = false; handleInteraction(e); }; - const handleMouseMove = (e: React.MouseEvent) => { + const handlePointerMove = (e: React.PointerEvent) => { if (!isDragging) return; handleInteraction(e); }; - const handleMouseUp = () => { + const handlePointerUp = (e: React.PointerEvent) => { setIsDragging(false); + // 指を離したとき、時モードなら分モードへ if (mode === 'hour' && hasHourShiftedRef.current) { setMode('minute'); } hasHourShiftedRef.current = false; }; + const handleChangeAMPM = (v: string) => { + setDate(prevDate => { + const newDate = new Date(prevDate); + const currentHours = newDate.getHours(); + if (v === 'PM' && currentHours < 12) { + newDate.setHours(currentHours + 12); + } else if (v === 'AM' && currentHours >= 12) { + newDate.setHours(currentHours - 12); + } + return newDate; + }); + }; + const hours = Array.from({ length: 12 }, (_, i) => i + 1); const minutes = Array.from({ length: 12 }, (_, i) => i * 5); @@ -149,11 +150,10 @@ function ClockTimePicker() { tabIndex={0} ref={clockRef} className='relative cursor-pointer select-none rounded-full bg-blue-50' - style={{ width: CLOCK_SIZE, height: CLOCK_SIZE }} - onMouseDown={handleMouseDown} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} + style={{ width: CLOCK_SIZE, height: CLOCK_SIZE, touchAction: 'none' }} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} > {/* Numbers will be placed here */} {(mode === 'hour' ? hours : minutes).map(value => { diff --git a/frontend/src/components/ui/time-picker.tsx b/frontend/src/components/ui/time-picker.tsx index 280798b0..201ec999 100644 --- a/frontend/src/components/ui/time-picker.tsx +++ b/frontend/src/components/ui/time-picker.tsx @@ -90,7 +90,14 @@ function TimePicker() { -
+
{hours.map(hour => ( - -
{hour}
+ +
{hour}
))}
@@ -122,11 +129,14 @@ function TimePicker() { setApi={setMinuteApi} opts={{ loop: true, + skipSnaps: false, + dragFree: true, }} orientation='vertical' className='w-full' style={{ maskImage: 'linear-gradient(to bottom, transparent, black 25%, black 75%, transparent)', + touchAction: 'none', }} onWheel={e => { if (e.deltaY < 0) { @@ -138,8 +148,8 @@ function TimePicker() { > {minutes.map(minute => ( - -
+ +
{minute.toString().padStart(2, '0')}
From 45cdbff380df07df4e42016e39e1e8eb82991071 Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 25 Mar 2026 16:10:39 +0000 Subject: [PATCH 11/15] update: package-lock.json --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d8b478aa..eb5fc52a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -968,9 +968,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, From a8bd16f071370e731b7ce866845e9684022b4675 Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 25 Mar 2026 16:20:13 +0000 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20=E6=B3=A2=E6=8B=AC=E5=BC=A7?= =?UTF-8?q?=E3=81=AE=E4=BF=AE=E6=AD=A3=E3=81=A8=E9=87=8D=E8=A4=87=E3=81=AE?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 40 -------------------------------------- 1 file changed, 40 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b82cc44f..f0d7cc64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,6 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dayjs": "^1.11.18", - "embla-carousel-react": "^8.6.0", "dexie": "^4.3.0", "install": "^0.13.0", "jotai": "^2.18.1", @@ -7563,10 +7562,6 @@ "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7753,10 +7748,6 @@ } } }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", - "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -7940,11 +7931,6 @@ } } }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", - "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", - "license": "MIT", "node_modules/glob/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -10605,20 +10591,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.171", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.171.tgz", - "integrity": "sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==", - "dev": true, - "license": "ISC" - }, "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", @@ -10647,18 +10619,6 @@ "embla-carousel": "8.6.0" } }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", - "license": "MIT", "node_modules/npm/node_modules/@npmcli/map-workspaces": { "version": "5.0.3", "inBundle": true, From d6ad9105bc3b9952d7715756d50a57c8532b97dd Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Wed, 25 Mar 2026 16:24:27 +0000 Subject: [PATCH 13/15] not use value --- frontend/src/components/ui/clock-time-picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ui/clock-time-picker.tsx b/frontend/src/components/ui/clock-time-picker.tsx index 0a6615e8..990c14cd 100644 --- a/frontend/src/components/ui/clock-time-picker.tsx +++ b/frontend/src/components/ui/clock-time-picker.tsx @@ -81,7 +81,7 @@ function ClockTimePicker() { handleInteraction(e); }; - const handlePointerUp = (e: React.PointerEvent) => { + const handlePointerUp = (_e: React.PointerEvent) => { setIsDragging(false); // 指を離したとき、時モードなら分モードへ if (mode === 'hour' && hasHourShiftedRef.current) { From 48d359b3378fb144438e1241af883ba833e5fa5d Mon Sep 17 00:00:00 2001 From: TxMk2 Date: Mon, 30 Mar 2026 16:46:23 +0000 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20input=E8=A6=81=E7=B4=A0=E3=81=AEi?= =?UTF-8?q?nterface=E9=81=A9=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ui/clock-time-picker.tsx | 173 +++++++++++++----- frontend/src/components/ui/time-picker.tsx | 125 +++++++++---- 2 files changed, 216 insertions(+), 82 deletions(-) diff --git a/frontend/src/components/ui/clock-time-picker.tsx b/frontend/src/components/ui/clock-time-picker.tsx index 990c14cd..6448d814 100644 --- a/frontend/src/components/ui/clock-time-picker.tsx +++ b/frontend/src/components/ui/clock-time-picker.tsx @@ -10,44 +10,90 @@ const CLOCK_SIZE = 200; const CLOCK_CENTER = CLOCK_SIZE / 2; const NUMBER_RADIUS = CLOCK_SIZE / 2 - 20; -function ClockTimePicker() { - const [date, setDate] = React.useState(new Date()); +type ClockTimePickerProps = Omit, 'type' | 'value' | 'onChange'> & { + value?: string; + onChange?: (event: React.ChangeEvent) => void; +}; + +function ClockTimePicker({ value, onChange, className, ...props }: ClockTimePickerProps) { + const parseValue = (val: string | undefined): Date => { + const d = new Date(); + if (val && /^([01]\d|2[0-3]):([0-5]\d)$/.test(val)) { + const [hours, minutes] = val.split(':').map(Number); + d.setHours(hours, minutes, 0, 0); + } + return d; + }; + + const [date, setDate] = React.useState(() => parseValue(value)); const [isOpen, setIsOpen] = React.useState(false); const [mode, setMode] = React.useState<'hour' | 'minute'>('hour'); const clockRef = React.useRef(null); + const inputRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); const hasHourShiftedRef = React.useRef(false); + // valueプロップスが変更された場合に内部状態を同期 + React.useEffect(() => { + if (value !== undefined) { + setDate(parseValue(value)); + } + }, [value]); + + const formatTime = (d: Date): string => { + const hours = d.getHours().toString().padStart(2, '0'); + const minutes = d.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + }; + const handleTimeUpdate = (newTime: { hour?: number; minute?: number }) => { - setDate(prevDate => { - const newDate = new Date(prevDate); - const currentHours = newDate.getHours(); - let hourWasActuallyChanged = false; - - if (newTime.hour !== undefined) { - let targetHour = newTime.hour; - // 12時間制の値を24時間制に変換 - if (targetHour === 12) { - targetHour = currentHours >= 12 ? 12 : 0; // 現在がPMなら12時PM、AMなら12時AM(0時) - } else { - // 1-11時の場合、現在のAM/PMを維持 - targetHour = currentHours >= 12 ? targetHour + 12 : targetHour; - } - - if (currentHours !== targetHour) { - hourWasActuallyChanged = true; - } - newDate.setHours(targetHour); - } - if (newTime.minute !== undefined) { - newDate.setMinutes(newTime.minute); + const newDate = new Date(date); + const currentHours = newDate.getHours(); + let hourWasActuallyChanged = false; + + if (newTime.hour !== undefined) { + let targetHour = newTime.hour; + // 12時間制の値を24時間制に変換 + if (targetHour === 12) { + targetHour = currentHours >= 12 ? 12 : 0; // 現在がPMなら12時PM、AMなら12時AM(0時) + } else { + // 1-11時の場合、現在のAM/PMを維持 + targetHour = currentHours >= 12 ? targetHour + 12 : targetHour; } - if (hourWasActuallyChanged) { - hasHourShiftedRef.current = true; + if (currentHours !== targetHour) { + hourWasActuallyChanged = true; } - return newDate; - }); + newDate.setHours(targetHour); + } + if (newTime.minute !== undefined) { + newDate.setMinutes(newTime.minute); + } + + if (hourWasActuallyChanged) { + hasHourShiftedRef.current = true; + } + + // 内部状態を更新 + setDate(newDate); + + // onChangeイベントを発火 + if (onChange && inputRef.current) { + const formatted = formatTime(newDate); + const event = { + target: { + ...inputRef.current, + value: formatted, + name: props.name, + }, + currentTarget: { + ...inputRef.current, + value: formatted, + name: props.name, + }, + } as React.ChangeEvent; + onChange(event); + } }; const handleInteraction = (e: React.PointerEvent) => { @@ -91,16 +137,32 @@ function ClockTimePicker() { }; const handleChangeAMPM = (v: string) => { - setDate(prevDate => { - const newDate = new Date(prevDate); - const currentHours = newDate.getHours(); - if (v === 'PM' && currentHours < 12) { - newDate.setHours(currentHours + 12); - } else if (v === 'AM' && currentHours >= 12) { - newDate.setHours(currentHours - 12); - } - return newDate; - }); + const newDate = new Date(date); + const currentHours = newDate.getHours(); + if (v === 'PM' && currentHours < 12) { + newDate.setHours(currentHours + 12); + } else if (v === 'AM' && currentHours >= 12) { + newDate.setHours(currentHours - 12); + } + + setDate(newDate); + + if (onChange && inputRef.current) { + const formatted = formatTime(newDate); + const event = { + target: { + ...inputRef.current, + value: formatted, + name: props.name, + }, + currentTarget: { + ...inputRef.current, + value: formatted, + name: props.name, + }, + } as React.ChangeEvent; + onChange(event); + } }; const hours = Array.from({ length: 12 }, (_, i) => i + 1); @@ -109,17 +171,28 @@ function ClockTimePicker() { return ( - +
+ + +
@@ -134,7 +207,7 @@ function ClockTimePicker() { {/* AM/PM toggle */}
-
diff --git a/frontend/src/components/ui/time-picker.tsx b/frontend/src/components/ui/time-picker.tsx index 201ec999..f60747b2 100644 --- a/frontend/src/components/ui/time-picker.tsx +++ b/frontend/src/components/ui/time-picker.tsx @@ -8,35 +8,86 @@ import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carouse import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; -function TimePicker() { - const [date, setDate] = React.useState(new Date()); +type TimePickerProps = Omit, 'type' | 'value' | 'onChange'> & { + value?: string; + onChange?: (event: React.ChangeEvent) => void; +}; + +function TimePicker({ value, onChange, className, ...props }: TimePickerProps) { + const parseValue = (val: string | undefined): Date => { + const d = new Date(); + if (val && /^([01]\d|2[0-3]):([0-5]\d)$/.test(val)) { + const [hours, minutes] = val.split(':').map(Number); + d.setHours(hours, minutes, 0, 0); + } + return d; + }; + + const [date, setDate] = React.useState(() => parseValue(value)); const [isOpen, setIsOpen] = React.useState(false); const [hourApi, setHourApi] = React.useState(); const [minuteApi, setMinuteApi] = React.useState(); + const inputRef = React.useRef(null); const hours = Array.from({ length: 12 }, (_, i) => i + 1); const minutes = Array.from({ length: 60 }, (_, i) => i); - const handleTimeChange = React.useCallback((type: 'hour' | 'minute' | 'ampm', value: string) => { - setDate(prevDate => { - const newDate = new Date(prevDate); - if (type === 'hour') { - const currentHours = newDate.getHours(); - const newHour = parseInt(value); - newDate.setHours((newHour % 12) + (currentHours >= 12 ? 12 : 0)); - } else if (type === 'minute') { - newDate.setMinutes(parseInt(value)); - } else if (type === 'ampm') { - const currentHours = newDate.getHours(); - if (value === 'PM' && currentHours < 12) { - newDate.setHours(currentHours + 12); - } else if (value === 'AM' && currentHours >= 12) { - newDate.setHours(currentHours - 12); + // valueプロップスが変更された場合に内部状態を同期 + React.useEffect(() => { + if (value !== undefined) { + setDate(parseValue(value)); + } + }, [value]); + + const formatTime = (d: Date): string => { + const hours = d.getHours().toString().padStart(2, '0'); + const minutes = d.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + }; + + const triggerOnChange = (newDate: Date) => { + if (onChange && inputRef.current) { + const formatted = formatTime(newDate); + const event = { + target: { + ...inputRef.current, + value: formatted, + name: props.name, + }, + currentTarget: { + ...inputRef.current, + value: formatted, + name: props.name, + }, + } as React.ChangeEvent; + onChange(event); + } + }; + + const handleTimeChange = React.useCallback( + (type: 'hour' | 'minute' | 'ampm', val: string) => { + setDate(prevDate => { + const newDate = new Date(prevDate); + if (type === 'hour') { + const currentHours = newDate.getHours(); + const newHour = parseInt(val); + newDate.setHours((newHour % 12) + (currentHours >= 12 ? 12 : 0)); + } else if (type === 'minute') { + newDate.setMinutes(parseInt(val)); + } else if (type === 'ampm') { + const currentHours = newDate.getHours(); + if (val === 'PM' && currentHours < 12) { + newDate.setHours(currentHours + 12); + } else if (val === 'AM' && currentHours >= 12) { + newDate.setHours(currentHours - 12); + } } - } - return newDate; - }); - }, []); + triggerOnChange(newDate); + return newDate; + }); + }, + [onChange, props.name] + ); React.useEffect(() => { if (!hourApi) return; @@ -77,17 +128,27 @@ function TimePicker() { return ( - +
+ + +
Date: Mon, 30 Mar 2026 16:50:48 +0000 Subject: [PATCH 15/15] fix: lint error --- frontend/src/components/ui/clock-time-picker.tsx | 9 +-------- frontend/src/components/ui/time-picker.tsx | 9 +-------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/ui/clock-time-picker.tsx b/frontend/src/components/ui/clock-time-picker.tsx index 6448d814..3f6365fc 100644 --- a/frontend/src/components/ui/clock-time-picker.tsx +++ b/frontend/src/components/ui/clock-time-picker.tsx @@ -172,14 +172,7 @@ function ClockTimePicker({ value, onChange, className, ...props }: ClockTimePick
- +