diff --git a/.changeset/fresh-crews-grow.md b/.changeset/fresh-crews-grow.md new file mode 100644 index 0000000000..67c06ceae1 --- /dev/null +++ b/.changeset/fresh-crews-grow.md @@ -0,0 +1,10 @@ +--- +"@heroui/number-input": patch +"@heroui/select": patch +"@heroui/date-input": patch +"@heroui/date-picker": patch +"@heroui/system": patch +"@heroui/theme": patch +--- + +`outside-top` label placement support (#5641, #5967) \ No newline at end of file diff --git a/apps/docs/content/components/autocomplete/label-placements.raw.jsx b/apps/docs/content/components/autocomplete/label-placements.raw.jsx index d66c2a4e7d..e298046774 100644 --- a/apps/docs/content/components/autocomplete/label-placements.raw.jsx +++ b/apps/docs/content/components/autocomplete/label-placements.raw.jsx @@ -29,13 +29,13 @@ export const animals = [ ]; export default function App() { - const placements = ["inside", "outside", "outside-left"]; + const placements = ["inside", "outside", "outside-left", "outside-top"]; return ( -
-
+
+

Without placeholder

-
+
{placements.map((placement) => (
-
+

With placeholder

-
+
{placements.map((placement) => ( diff --git a/apps/docs/content/components/date-picker/label-placements.raw.jsx b/apps/docs/content/components/date-picker/label-placements.raw.jsx index 953ac43ef1..b7f3a43cc6 100644 --- a/apps/docs/content/components/date-picker/label-placements.raw.jsx +++ b/apps/docs/content/components/date-picker/label-placements.raw.jsx @@ -1,7 +1,7 @@ import {DatePicker} from "@heroui/react"; export default function App() { - const placements = ["inside", "outside", "outside-left"]; + const placements = ["inside", "outside", "outside-left", "outside-top"]; return (
diff --git a/apps/docs/content/components/date-range-picker/label-placements.raw.jsx b/apps/docs/content/components/date-range-picker/label-placements.raw.jsx index fa29c8bf31..96ef354bb9 100644 --- a/apps/docs/content/components/date-range-picker/label-placements.raw.jsx +++ b/apps/docs/content/components/date-range-picker/label-placements.raw.jsx @@ -1,7 +1,7 @@ import {DateRangePicker} from "@heroui/react"; export default function App() { - const placements = ["inside", "outside", "outside-left"]; + const placements = ["inside", "outside", "outside-left", "outside-top"]; return (
diff --git a/apps/docs/content/components/number-input/label-placements.raw.jsx b/apps/docs/content/components/number-input/label-placements.raw.jsx index b1eabd4564..1197c7c28c 100644 --- a/apps/docs/content/components/number-input/label-placements.raw.jsx +++ b/apps/docs/content/components/number-input/label-placements.raw.jsx @@ -1,13 +1,13 @@ import {NumberInput} from "@heroui/react"; export default function App() { - const placements = ["inside", "outside", "outside-left"]; + const placements = ["inside", "outside", "outside-left", "outside-top"]; return ( -
-
+
+

Without placeholder

-
+
{placements.map((placement) => (
-
+

With placeholder

-
+
{placements.map((placement) => ( -
+
+

Without placeholder

-
+
{placements.map((placement) => ( +
); } diff --git a/apps/docs/content/docs/api-references/heroui-provider.mdx b/apps/docs/content/docs/api-references/heroui-provider.mdx index 1c64b93854..baa5f71784 100644 --- a/apps/docs/content/docs/api-references/heroui-provider.mdx +++ b/apps/docs/content/docs/api-references/heroui-provider.mdx @@ -147,9 +147,9 @@ interface AppProviderProps { `labelPlacement` -- **Description**: Determines the position where label should appear, such as inside, outside or outside-left of the component. +- **Description**: Determines the position where label should appear, such as inside, outside, outside-left or outside-top of the component. - **Type**: `string` | `undefined` -- **Possible Values**: `inside` | `outside` | `outside-left` | `undefined` +- **Possible Values**: `inside` | `outside` | `outside-left` | `outside-top` | `undefined` - **Default**: `undefined` diff --git a/apps/docs/content/docs/components/autocomplete.mdx b/apps/docs/content/docs/components/autocomplete.mdx index 9bb459ab6f..209ca2ad48 100644 --- a/apps/docs/content/docs/components/autocomplete.mdx +++ b/apps/docs/content/docs/components/autocomplete.mdx @@ -102,7 +102,7 @@ all available options, but users won't be able to select any of the listed optio ### Label Placements -You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside` or `outside-left`. +You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside`, `outside-left` or `outside-top`. @@ -370,7 +370,7 @@ import {parseZonedDateTime} from "@internationalized/date"; }, { attribute: "labelPlacement", - type: "inside | outside | outside-left", + type: "inside | outside | outside-left | outside-top", description: "The position of the label.", default: "inside" }, diff --git a/apps/docs/content/docs/components/date-picker.mdx b/apps/docs/content/docs/components/date-picker.mdx index 6cd73efbe8..baf57e4b25 100644 --- a/apps/docs/content/docs/components/date-picker.mdx +++ b/apps/docs/content/docs/components/date-picker.mdx @@ -59,7 +59,7 @@ DatePickers combine a DateInput and a Calendar popover to allow users to enter o ### Label Placements -You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside` or `outside-left`. +You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside`, `outside-left` or `outside-top`. @@ -407,7 +407,7 @@ import {I18nProvider} from "@react-aria/i18n"; }, { attribute: "labelPlacement", - type: "inside | outside | outside-left", + type: "inside | outside | outside-left | outside-top", description: "The position of the label.", default: "inside" }, diff --git a/apps/docs/content/docs/components/date-range-picker.mdx b/apps/docs/content/docs/components/date-range-picker.mdx index 6bec315131..04f59c1ec0 100644 --- a/apps/docs/content/docs/components/date-range-picker.mdx +++ b/apps/docs/content/docs/components/date-range-picker.mdx @@ -80,7 +80,7 @@ By default, when pressing the next or previous buttons, pagination will advance ### Label Placements -You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside` or `outside-left`. +You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside`, `outside-left` or `outside-top`. @@ -478,7 +478,7 @@ You can customize the `DateRangePicker` component by passing custom Tailwind CSS }, { attribute: "labelPlacement", - type: "inside | outside | outside-left", + type: "inside | outside | outside-left | outside-top", description: "The position of the label.", default: "inside" }, diff --git a/apps/docs/content/docs/components/input.mdx b/apps/docs/content/docs/components/input.mdx index 751f4f9234..0b584ef335 100644 --- a/apps/docs/content/docs/components/input.mdx +++ b/apps/docs/content/docs/components/input.mdx @@ -353,7 +353,7 @@ In case you need to customize the input even further, you can use the `useInput` }, { attribute: "labelPlacement", - type: "inside | outside | outside-left", + type: "inside | outside | outside-left | outside-top", description: "The position of the label.", default: "inside" }, diff --git a/apps/docs/content/docs/components/number-input.mdx b/apps/docs/content/docs/components/number-input.mdx index a027e2aec1..a79da5cd7e 100644 --- a/apps/docs/content/docs/components/number-input.mdx +++ b/apps/docs/content/docs/components/number-input.mdx @@ -65,12 +65,16 @@ the end of the label and the input will be required. ### Label Placements -You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside` or `outside-left`. +You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside`, `outside-left` or `outside-top`. > **Note**: If the `label` is not passed, the `labelPlacement` property will be `outside` by default. +> **Note**: If the `labelPlacement` is `outside`, `label` is outside only when a placeholder is provided. + +> **Note**: If the `labelPlacement` is `outside-top` or `outside-left`, `label` is outside even if a placeholder is not provided. + ### Clear Button If you pass the `isClearable` property to the input, it will have a clear button at the @@ -373,7 +377,7 @@ You can customize the `NumberInput` component by passing custom Tailwind CSS cla }, { attribute: "labelPlacement", - type: "inside | outside | outside-left", + type: "inside | outside | outside-left | outside-top", description: "The position of the label.", default: "inside" }, diff --git a/apps/docs/content/docs/components/select.mdx b/apps/docs/content/docs/components/select.mdx index f7fa0ee054..22c2c4fe5a 100644 --- a/apps/docs/content/docs/components/select.mdx +++ b/apps/docs/content/docs/components/select.mdx @@ -97,12 +97,16 @@ the end of the label and the select will be required. ### Label Placements -You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside` or `outside-left`. +You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside`, `outside-left` or `outside-top`. > **Note**: If the `label` is not passed, the `labelPlacement` property will be `outside` by default. +> **Note**: If the `labelPlacement` is `outside`, `label` is outside only when a placeholder is provided. + +> **Note**: If the `labelPlacement` is `outside-top` or `outside-left`, `label` is outside even if a placeholder is not provided. + ### Start Content You can use the `startContent` properties to add content to the start of the select. @@ -476,7 +480,7 @@ If you need to submit a specific `value` instead of the `key` during form submis }, { attribute: "labelPlacement", - type: "inside | outside | outside-left", + type: "inside | outside | outside-left | outside-top", description: "The position of the label.", default: "inside" }, diff --git a/apps/docs/content/docs/components/time-input.mdx b/apps/docs/content/docs/components/time-input.mdx index 69546b96da..bf29aead18 100644 --- a/apps/docs/content/docs/components/time-input.mdx +++ b/apps/docs/content/docs/components/time-input.mdx @@ -89,11 +89,13 @@ You can also pass an error message as a function. This allows for dynamic error -### Label Placement +### Label Placements -The label's overall position relative to the element it is labeling. +You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside`, `outside-left` or `outside-top`. - + + +> **Note**: If the `label` is not passed, the `labelPlacement` property will be `outside` by default. ### Start Content @@ -279,7 +281,7 @@ By default, `TimeInput` displays times in either 12 or 24 hour hour format depen }, { attribute: "labelPlacement", - type: "inside | outside | outside-left", + type: "inside | outside | outside-left | outside-top", description: "The position of the label.", default: "inside" }, diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index f55ecdf0d9..e42642be65 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -8,6 +8,7 @@ import userEvent from "@testing-library/user-event"; import {spy, shouldIgnoreReactWarning} from "@heroui/test-utils"; import {useForm} from "react-hook-form"; import {Form} from "@heroui/form"; +import {HeroUIProvider} from "@heroui/system"; import {Autocomplete, AutocompleteItem, AutocompleteSection} from "../src"; import {Modal, ModalContent, ModalBody, ModalHeader, ModalFooter} from "../../modal/src"; @@ -860,6 +861,55 @@ describe("Autocomplete", () => { }); }); }); + + describe("Autocomplete with HeroUIProvider context", () => { + it("should inherit labelPlacement from HeroUIProvider", () => { + const {container} = render( + + + {(item) => {item.label}} + + , + ); + + const label = container.querySelector("label"); + + expect(label).toBeTruthy(); + expect(label?.className).toMatch(/translate-y.*100%/); + }); + + it("should prioritize labelPlacement prop over HeroUIProvider context", () => { + const {container} = render( + + + {(item) => {item.label}} + + , + ); + + const label = container.querySelector("label"); + + expect(label?.className).not.toMatch(/translate-y.*100%/); + }); + + it("should inherit labelPlacement='outside-top' from HeroUIProvider", () => { + const {container} = render( + + + {(item) => {item.label}} + + , + ); + + const label = container.querySelector("label"); + const mainWrapper = container.querySelector("[data-slot=main-wrapper]"); + + expect(label).toBeTruthy(); + // outside-top uses flex-col on mainWrapper and relative label (no translate-y) + expect(mainWrapper).toHaveClass("flex-col"); + expect(label?.className).not.toMatch(/translate-y.*100%/); + }); + }); }); describe("Autocomplete with React Hook Form", () => { diff --git a/packages/components/date-input/__tests__/date-input.test.tsx b/packages/components/date-input/__tests__/date-input.test.tsx index 8c19867e0f..c8178d3325 100644 --- a/packages/components/date-input/__tests__/date-input.test.tsx +++ b/packages/components/date-input/__tests__/date-input.test.tsx @@ -7,6 +7,7 @@ import {fireEvent, render} from "@testing-library/react"; import {CalendarDate, CalendarDateTime, ZonedDateTime} from "@internationalized/date"; import {pointerMap, triggerPress} from "@heroui/test-utils"; import userEvent from "@testing-library/user-event"; +import {HeroUIProvider} from "@heroui/system"; import {DateInput as DateInputBase} from "../src"; @@ -338,4 +339,55 @@ describe("DateInput", () => { expect(input).toHaveValue("2020-02-03"); }); }); + describe("DateInput with HeroUIProvider context", () => { + it("should inherit labelPlacement from HeroUIProvider", () => { + const labelContent = "Test date input label"; + + render( + + + , + ); + + const label = document.querySelector("[data-slot=label]"); + const group = document.querySelector("[data-slot=input-wrapper]"); + + expect(label).toHaveTextContent(labelContent); + expect(group).not.toHaveTextContent(labelContent); + }); + + it("should prioritize labelPlacement prop over HeroUIProvider context", () => { + const labelContent = "Test date input label"; + + render( + + + , + ); + + const label = document.querySelector("[data-slot=label]"); + const group = document.querySelector("[data-slot=input-wrapper]"); + + expect(label).toHaveTextContent(labelContent); + expect(group).toHaveTextContent(labelContent); + }); + + it("should inherit labelPlacement='outside-top' from HeroUIProvider", () => { + const labelContent = "Test date input label"; + + const {container} = render( + + + , + ); + + const label = document.querySelector("[data-slot=label]"); + const group = document.querySelector("[data-slot=input-wrapper]"); + const base = container.querySelector("[data-slot=base]"); + + expect(label).toHaveTextContent(labelContent); + expect(group).not.toHaveTextContent(labelContent); + expect(base).toHaveClass("flex-col"); + }); + }); }); diff --git a/packages/components/date-input/__tests__/time-input.test.tsx b/packages/components/date-input/__tests__/time-input.test.tsx index 95896615f5..351cebc5bf 100644 --- a/packages/components/date-input/__tests__/time-input.test.tsx +++ b/packages/components/date-input/__tests__/time-input.test.tsx @@ -7,6 +7,7 @@ import {fireEvent, render} from "@testing-library/react"; import {Time, ZonedDateTime} from "@internationalized/date"; import {pointerMap, triggerPress} from "@heroui/test-utils"; import userEvent from "@testing-library/user-event"; +import {HeroUIProvider} from "@heroui/system"; import {TimeInput as TimeInputBase} from "../src"; @@ -443,4 +444,56 @@ describe("TimeInput", () => { expect(document.querySelector("[data-slot=error-message]")).toBeVisible(); }); }); + + describe("TimeInput with HeroUIProvider context", () => { + it("should inherit labelPlacement from HeroUIProvider", () => { + const labelContent = "Test time input label"; + + render( + + + , + ); + + const label = document.querySelector("[data-slot=label]"); + const group = document.querySelector("[data-slot=input-wrapper]"); + + expect(label).toHaveTextContent(labelContent); + expect(group).not.toHaveTextContent(labelContent); + }); + + it("should prioritize labelPlacement prop over HeroUIProvider context", () => { + const labelContent = "Test time input label"; + + render( + + + , + ); + + const label = document.querySelector("[data-slot=label]"); + const group = document.querySelector("[data-slot=input-wrapper]"); + + expect(label).toHaveTextContent(labelContent); + expect(group).toHaveTextContent(labelContent); + }); + + it("should inherit labelPlacement='outside-top' from HeroUIProvider", () => { + const labelContent = "Test time input label"; + + const {container} = render( + + + , + ); + + const label = document.querySelector("[data-slot=label]"); + const group = document.querySelector("[data-slot=input-wrapper]"); + const base = container.querySelector("[data-slot=base]"); + + expect(label).toHaveTextContent(labelContent); + expect(group).not.toHaveTextContent(labelContent); + expect(base).toHaveClass("flex-col"); + }); + }); }); diff --git a/packages/components/date-input/package.json b/packages/components/date-input/package.json index 4b744eb333..80a58a9046 100644 --- a/packages/components/date-input/package.json +++ b/packages/components/date-input/package.json @@ -35,7 +35,7 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" }, diff --git a/packages/components/date-input/src/use-date-input.ts b/packages/components/date-input/src/use-date-input.ts index f06afaab89..27e41a7d59 100644 --- a/packages/components/date-input/src/use-date-input.ts +++ b/packages/components/date-input/src/use-date-input.ts @@ -202,7 +202,10 @@ export function useDateInput(originalProps: UseDateInputPro label, }); - const shouldLabelBeOutside = labelPlacement === "outside" || labelPlacement === "outside-left"; + const shouldLabelBeOutside = + labelPlacement === "outside" || + labelPlacement === "outside-left" || + labelPlacement === "outside-top"; const slots = useMemo( () => diff --git a/packages/components/date-input/src/use-time-input.ts b/packages/components/date-input/src/use-time-input.ts index b13b8670db..e3f66e09c8 100644 --- a/packages/components/date-input/src/use-time-input.ts +++ b/packages/components/date-input/src/use-time-input.ts @@ -139,7 +139,10 @@ export function useTimeInput(originalProps: UseTimeInputPro label, }); - const shouldLabelBeOutside = labelPlacement === "outside" || labelPlacement === "outside-left"; + const shouldLabelBeOutside = + labelPlacement === "outside" || + labelPlacement === "outside-left" || + labelPlacement === "outside-top"; const slots = useMemo( () => diff --git a/packages/components/date-input/stories/date-input.stories.tsx b/packages/components/date-input/stories/date-input.stories.tsx index 3a9e56c005..36f74b4b7f 100644 --- a/packages/components/date-input/stories/date-input.stories.tsx +++ b/packages/components/date-input/stories/date-input.stories.tsx @@ -51,7 +51,7 @@ export default { control: { type: "select", }, - options: ["inside", "outside", "outside-left"], + options: ["inside", "outside", "outside-left", "outside-top"], }, isDisabled: { control: { @@ -96,6 +96,7 @@ const LabelPlacementTemplate = (args: DateInputProps) => ( +
); diff --git a/packages/components/date-input/stories/time-input.stories.tsx b/packages/components/date-input/stories/time-input.stories.tsx index fcab248a7d..39e6b0e434 100644 --- a/packages/components/date-input/stories/time-input.stories.tsx +++ b/packages/components/date-input/stories/time-input.stories.tsx @@ -47,7 +47,7 @@ export default { control: { type: "select", }, - options: ["inside", "outside", "outside-left"], + options: ["inside", "outside", "outside-left", "outside-top"], }, isDisabled: { control: { @@ -90,6 +90,7 @@ const LabelPlacementTemplate = (args: TimeInputProps) => ( +
); diff --git a/packages/components/date-picker/__tests__/date-picker.test.tsx b/packages/components/date-picker/__tests__/date-picker.test.tsx index 953a119967..ccd4719131 100644 --- a/packages/components/date-picker/__tests__/date-picker.test.tsx +++ b/packages/components/date-picker/__tests__/date-picker.test.tsx @@ -975,3 +975,58 @@ describe("DatePicker", () => { }); }); }); + +describe("DatePicker with HeroUIProvider context", () => { + it("should inherit labelPlacement from HeroUIProvider", () => { + const labelContent = "Test DatePicker"; + + const {container} = render( + + + , + ); + + const label = container.querySelector("span[data-slot=label]"); + const inputWrapper = container.querySelector("div[data-slot=input-wrapper]"); + const base = container.querySelector("[data-slot=base]"); + + expect(label).toHaveTextContent(labelContent); + expect(inputWrapper).not.toHaveTextContent(labelContent); + expect(base).toContainElement(label as HTMLElement); + }); + + it("should inherit labelPlacement='outside-top' from HeroUIProvider", () => { + const labelContent = "Test DatePicker"; + + const {container} = render( + + + , + ); + + const label = container.querySelector("span[data-slot=label]"); + const inputWrapper = container.querySelector("div[data-slot=input-wrapper]"); + const base = container.querySelector("[data-slot=base]"); + + expect(label).toHaveTextContent(labelContent); + expect(inputWrapper).not.toHaveTextContent(labelContent); + expect(base).toHaveClass("flex-col"); + }); + + it("should inherit labelPlacement='inside' from HeroUIProvider", () => { + const labelContent = "Test DatePicker"; + + const {container} = render( + + + , + ); + + const label = container.querySelector("span[data-slot=label]"); + const inputWrapper = container.querySelector("div[data-slot=input-wrapper]"); + + expect(label).toHaveTextContent(labelContent); + // In inside placement, label is inside inputWrapper + expect(inputWrapper).toContainElement(label as HTMLElement); + }); +}); diff --git a/packages/components/date-picker/src/use-date-range-picker.ts b/packages/components/date-picker/src/use-date-range-picker.ts index 75bbceeab4..6b8e9f7703 100644 --- a/packages/components/date-picker/src/use-date-range-picker.ts +++ b/packages/components/date-picker/src/use-date-range-picker.ts @@ -155,7 +155,10 @@ export function useDateRangePicker({ label, }); - const shouldLabelBeOutside = labelPlacement === "outside" || labelPlacement === "outside-left"; + const shouldLabelBeOutside = + labelPlacement === "outside" || + labelPlacement === "outside-left" || + labelPlacement === "outside-top"; /** * ------------------------------ diff --git a/packages/components/date-picker/stories/date-picker.stories.tsx b/packages/components/date-picker/stories/date-picker.stories.tsx index 2c09be9c1d..0e5a9beb1d 100644 --- a/packages/components/date-picker/stories/date-picker.stories.tsx +++ b/packages/components/date-picker/stories/date-picker.stories.tsx @@ -59,7 +59,7 @@ export default { control: { type: "select", }, - options: ["inside", "outside", "outside-left"], + options: ["inside", "outside", "outside-left", "outside-top"], }, isDisabled: { control: { @@ -116,6 +116,7 @@ const LabelPlacementTemplate = (args: DatePickerProps) => ( +
); diff --git a/packages/components/date-picker/stories/date-range-picker.stories.tsx b/packages/components/date-picker/stories/date-range-picker.stories.tsx index 4df67cf2de..28fd1bdbbe 100644 --- a/packages/components/date-picker/stories/date-range-picker.stories.tsx +++ b/packages/components/date-picker/stories/date-range-picker.stories.tsx @@ -60,7 +60,7 @@ export default { control: { type: "select", }, - options: ["inside", "outside", "outside-left"], + options: ["inside", "outside", "outside-left", "outside-top"], }, isDisabled: { control: { @@ -116,6 +116,7 @@ const LabelPlacementTemplate = (args: DateRangePickerProps) => ( +
); diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index 754d424043..926b34fcda 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -5,6 +5,7 @@ import {render, renderHook, fireEvent, act} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import {useForm} from "react-hook-form"; import {Form} from "@heroui/form"; +import {HeroUIProvider} from "@heroui/system"; import {Input} from "../src"; @@ -666,3 +667,53 @@ describe("Input with React Hook Form", () => { }); }); }); + +describe("Input with HeroUIProvider context", () => { + it("should inherit labelPlacement from HeroUIProvider", () => { + const labelContent = "Test input label"; + + const {container} = render( + + + , + ); + + const label = container.querySelector("label"); + + expect(label).toBeTruthy(); + expect(label?.className).toMatch(/translate-y.*100%/); + }); + + it("should prioritize labelPlacement prop over HeroUIProvider context", () => { + const labelContent = "Test input label"; + + const {container} = render( + + + , + ); + + const label = container.querySelector("label"); + + expect(label).toBeTruthy(); + expect(label?.className).not.toMatch(/translate-y.*100%/); + }); + + it("should inherit labelPlacement='outside-top' from HeroUIProvider", () => { + const labelContent = "Test input label"; + + const {container} = render( + + + , + ); + + const label = container.querySelector("label"); + const mainWrapper = container.querySelector("[data-slot=main-wrapper]"); + + expect(label).toBeTruthy(); + // outside-top uses flex-col on mainWrapper and relative label (no translate-y) + expect(mainWrapper).toHaveClass("flex-col"); + expect(label?.className).not.toMatch(/translate-y.*100%/); + }); +}); diff --git a/packages/components/input/src/use-input.ts b/packages/components/input/src/use-input.ts index 5a6cedb3ac..08038fb40f 100644 --- a/packages/components/input/src/use-input.ts +++ b/packages/components/input/src/use-input.ts @@ -4,7 +4,7 @@ import type {HTMLHeroUIProps, PropGetter} from "@heroui/system"; import type {AriaTextFieldProps} from "@react-types/textfield"; import type {Ref} from "react"; -import {mapPropsVariants, useProviderContext, useInputLabelPlacement} from "@heroui/system"; +import {mapPropsVariants, useProviderContext, useLabelPlacement} from "@heroui/system"; import {useSafeLayoutEffect} from "@heroui/use-safe-layout-effect"; import {useFocusRing} from "@react-aria/focus"; import {input} from "@heroui/theme"; @@ -234,7 +234,7 @@ export function useInput { expect(label).toBeTruthy(); expect(label?.className).not.toMatch(/translate-y.*100%/); }); + + it("should inherit labelPlacement='outside-top' from HeroUIProvider", () => { + const {container} = render( + + + , + ); + + const label = container.querySelector("label"); + const mainWrapper = container.querySelector("[data-slot=main-wrapper]"); + + expect(label).toBeTruthy(); + // outside-top uses flex-col on mainWrapper and relative label (no translate-y) + expect(mainWrapper).toHaveClass("flex-col"); + expect(label?.className).not.toMatch(/translate-y.*100%/); + }); }); }); diff --git a/packages/components/number-input/src/number-input.tsx b/packages/components/number-input/src/number-input.tsx index fb95df6a15..6d648dde88 100644 --- a/packages/components/number-input/src/number-input.tsx +++ b/packages/components/number-input/src/number-input.tsx @@ -22,6 +22,7 @@ const NumberInput = forwardRef<"input", NumberInputProps>((props, ref) => { labelPlacement, hasHelper, isOutsideLeft, + isOutsideTop, shouldLabelBeOutside, errorMessage, isInvalid, @@ -106,7 +107,7 @@ const NumberInput = forwardRef<"input", NumberInputProps>((props, ref) => { return (
- {!isOutsideLeft ? labelContent : null} + {!isOutsideLeft && !isOutsideTop ? labelContent : null} {innerWrapper}
{helperWrapper} @@ -139,7 +140,7 @@ const NumberInput = forwardRef<"input", NumberInputProps>((props, ref) => { return ( - {isOutsideLeft ? labelContent : null} + {isOutsideLeft || isOutsideTop ? labelContent : null} {mainWrapper} ); diff --git a/packages/components/number-input/src/use-number-input.ts b/packages/components/number-input/src/use-number-input.ts index 50eb687ddd..924324bebe 100644 --- a/packages/components/number-input/src/use-number-input.ts +++ b/packages/components/number-input/src/use-number-input.ts @@ -213,16 +213,21 @@ export function useNumberInput(originalProps: UseNumberInputProps) { const hasPlaceholder = !!props.placeholder; const hasLabel = !!label; const hasHelper = !!description || !!errorMessage; - const shouldLabelBeOutside = labelPlacement === "outside" || labelPlacement === "outside-left"; + const shouldLabelBeOutside = + labelPlacement === "outside" || + labelPlacement === "outside-left" || + labelPlacement === "outside-top"; const shouldLabelBeInside = labelPlacement === "inside"; const isPlaceholderShown = domRef.current ? (!domRef.current.value || domRef.current.value === "" || !inputValue) && hasPlaceholder : false; const isOutsideLeft = labelPlacement === "outside-left"; + const isOutsideTop = labelPlacement === "outside-top"; const hasStartContent = !!startContent; const isLabelOutside = shouldLabelBeOutside ? labelPlacement === "outside-left" || + isOutsideTop || hasPlaceholder || (labelPlacement === "outside" && hasStartContent) : false; @@ -601,6 +606,7 @@ export function useNumberInput(originalProps: UseNumberInputProps) { hasStartContent, isLabelOutside, isOutsideLeft, + isOutsideTop, isLabelOutsideAsPlaceholder, shouldLabelBeOutside, shouldLabelBeInside, diff --git a/packages/components/number-input/stories/number-input.stories.tsx b/packages/components/number-input/stories/number-input.stories.tsx index 2f7f9d8b6d..7311a50fcd 100644 --- a/packages/components/number-input/stories/number-input.stories.tsx +++ b/packages/components/number-input/stories/number-input.stories.tsx @@ -41,7 +41,7 @@ export default { control: { type: "select", }, - options: ["inside", "outside", "outside-left"], + options: ["inside", "outside", "outside-left", "outside-top"], }, isDisabled: { control: { @@ -109,6 +109,7 @@ const LabelPlacementTemplate = (args) => ( +
@@ -127,6 +128,12 @@ const LabelPlacementTemplate = (args) => ( labelPlacement="outside-left" placeholder="Enter a number" /> +
diff --git a/packages/components/select/__tests__/select.test.tsx b/packages/components/select/__tests__/select.test.tsx index b278aebf08..5073eec215 100644 --- a/packages/components/select/__tests__/select.test.tsx +++ b/packages/components/select/__tests__/select.test.tsx @@ -8,6 +8,7 @@ import userEvent from "@testing-library/user-event"; import {spy, shouldIgnoreReactWarning} from "@heroui/test-utils"; import {useForm} from "react-hook-form"; import {Form} from "@heroui/form"; +import {HeroUIProvider} from "@heroui/system"; import {Select, SelectItem, SelectSection} from "../src"; import {Modal, ModalContent, ModalHeader, ModalBody, ModalFooter} from "../../modal/src"; @@ -1705,4 +1706,87 @@ describe("validation", () => { ); }); }); + + describe("Select with HeroUIProvider context", () => { + it("should inherit labelPlacement from HeroUIProvider", () => { + const labelContent = "Favorite Animal Label"; + + render( + + + , + ); + + const base = document.querySelector("[data-slot=base]"); + const trigger = document.querySelector("[data-slot=trigger]"); + + expect(base).toHaveTextContent(labelContent); + expect(trigger).not.toHaveTextContent(labelContent); + }); + + it("should prioritize labelPlacement prop over HeroUIProvider context", () => { + const labelContent = "Favorite Animal Label"; + + render( + + + , + ); + + const base = document.querySelector("[data-slot=base]"); + const trigger = document.querySelector("[data-slot=trigger]"); + + expect(base).toHaveTextContent(labelContent); + expect(trigger).toHaveTextContent(labelContent); + }); + + it("should inherit labelPlacement='outside-top' from HeroUIProvider", () => { + const labelContent = "Favorite Animal Label"; + + render( + + + , + ); + + const base = document.querySelector("[data-slot=base]"); + const trigger = document.querySelector("[data-slot=trigger]"); + + // outside-top: label is in base, not trigger + expect(base).toHaveTextContent(labelContent); + expect(trigger).not.toHaveTextContent(labelContent); + expect(base).toHaveClass("flex-col"); + }); + }); }); diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index 76bf81bc48..2f853f467c 100644 --- a/packages/components/select/src/use-select.ts +++ b/packages/components/select/src/use-select.ts @@ -361,7 +361,10 @@ export function useSelect(originalProps: UseSelectProps) { }); const hasPlaceholder = !!placeholder; - const shouldLabelBeOutside = labelPlacement === "outside-left" || labelPlacement === "outside"; + const shouldLabelBeOutside = + labelPlacement === "outside-left" || + labelPlacement === "outside" || + labelPlacement === "outside-top"; const shouldLabelBeInside = labelPlacement === "inside"; const isOutsideLeft = labelPlacement === "outside-left"; const isClearable = originalProps.isClearable; diff --git a/packages/components/select/stories/select.stories.tsx b/packages/components/select/stories/select.stories.tsx index 16e4e06166..b0f99e54cf 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -50,7 +50,7 @@ export default { control: { type: "select", }, - options: ["inside", "outside", "outside-left"], + options: ["inside", "outside", "outside-left", "outside-top"], }, isDisabled: { control: { @@ -412,6 +412,15 @@ const LabelPlacementTemplate = ({color, variant, ...args}: SelectProps) => ( > {items} +
@@ -446,6 +455,16 @@ const LabelPlacementTemplate = ({color, variant, ...args}: SelectProps) => ( > {items} +
@@ -483,6 +502,17 @@ const LabelPlacementTemplate = ({color, variant, ...args}: SelectProps) => ( > {items} +
diff --git a/packages/core/system/src/hooks/index.ts b/packages/core/system/src/hooks/index.ts index 151ec3840f..752604dc49 100644 --- a/packages/core/system/src/hooks/index.ts +++ b/packages/core/system/src/hooks/index.ts @@ -1 +1 @@ -export {useLabelPlacement, useInputLabelPlacement} from "./use-label-placement"; +export {useLabelPlacement} from "./use-label-placement"; diff --git a/packages/core/system/src/hooks/use-label-placement.ts b/packages/core/system/src/hooks/use-label-placement.ts index 9887d7a8cf..d1fec900e3 100644 --- a/packages/core/system/src/hooks/use-label-placement.ts +++ b/packages/core/system/src/hooks/use-label-placement.ts @@ -3,24 +3,6 @@ import {useMemo} from "react"; import {useProviderContext} from "../provider-context"; export function useLabelPlacement(props: { - labelPlacement?: "inside" | "outside" | "outside-left"; - label?: React.ReactNode; -}) { - const globalContext = useProviderContext(); - const globalLabelPlacement = globalContext?.labelPlacement; - - return useMemo(() => { - const labelPlacement = props.labelPlacement ?? globalLabelPlacement ?? "inside"; - - if (labelPlacement === "inside" && !props.label) { - return "outside"; - } - - return labelPlacement; - }, [props.labelPlacement, globalLabelPlacement, props.label]); -} - -export function useInputLabelPlacement(props: { labelPlacement?: "inside" | "outside" | "outside-left" | "outside-top"; label?: React.ReactNode; }) { diff --git a/packages/core/system/src/index.ts b/packages/core/system/src/index.ts index ebc192950c..55e65fdb5a 100644 --- a/packages/core/system/src/index.ts +++ b/packages/core/system/src/index.ts @@ -33,4 +33,4 @@ export type {ProviderContextProps} from "./provider-context"; export {HeroUIProvider} from "./provider"; export {ProviderContext, useProviderContext} from "./provider-context"; -export {useLabelPlacement, useInputLabelPlacement} from "./hooks"; +export {useLabelPlacement} from "./hooks"; diff --git a/packages/core/system/src/provider-context.ts b/packages/core/system/src/provider-context.ts index b836264bcf..155fe976ff 100644 --- a/packages/core/system/src/provider-context.ts +++ b/packages/core/system/src/provider-context.ts @@ -14,7 +14,7 @@ export type ProviderContextProps = { * * @default undefined */ - labelPlacement?: "inside" | "outside" | "outside-left" | undefined; + labelPlacement?: "inside" | "outside" | "outside-left" | "outside-top" | undefined; /** /** * Whether to disable the ripple effect in the whole application. diff --git a/packages/core/theme/src/components/date-input.ts b/packages/core/theme/src/components/date-input.ts index c0a80e84b6..de1f317d1c 100644 --- a/packages/core/theme/src/components/date-input.ts +++ b/packages/core/theme/src/components/date-input.ts @@ -171,6 +171,11 @@ const dateInput = tv({ inputWrapper: "relative flex-1", helperWrapper: "absolute top-[calc(100%_+_2px)] start-0", }, + "outside-top": { + base: "flex flex-col data-[has-helper=true]:pb-[calc(var(--heroui-font-size-tiny)_+8px)] gap-y-1.5", + label: "w-full text-foreground", + helperWrapper: "absolute top-[calc(100%_+_2px)] start-0", + }, inside: { label: "w-full text-tiny cursor-text", inputWrapper: "flex-col items-start justify-center gap-0", diff --git a/packages/core/theme/src/components/number-input.ts b/packages/core/theme/src/components/number-input.ts index 8124f77a11..ab61643136 100644 --- a/packages/core/theme/src/components/number-input.ts +++ b/packages/core/theme/src/components/number-input.ts @@ -212,6 +212,11 @@ const numberInput = tv({ label: "relative text-foreground pe-2 ps-2 pointer-events-auto", stepperButton: "min-w-3 w-3 h-3", }, + "outside-top": { + mainWrapper: "flex flex-col", + label: "relative text-foreground pb-2 pointer-events-auto", + stepperButton: "min-w-3 w-3 h-3", + }, inside: { label: "cursor-text", inputWrapper: "flex-col items-start justify-center gap-0", diff --git a/packages/core/theme/src/components/select.ts b/packages/core/theme/src/components/select.ts index ac30720227..c0f2f3d193 100644 --- a/packages/core/theme/src/components/select.ts +++ b/packages/core/theme/src/components/select.ts @@ -178,6 +178,11 @@ const select = tv({ label: "relative pe-2 text-foreground", clearButton: "mb-0", }, + "outside-top": { + base: "flex flex-col", + label: "relative text-foreground pb-2 pointer-events-auto", + clearButton: "mb-0", + }, inside: { label: "text-tiny cursor-pointer", trigger: "flex-col items-start justify-center gap-0",