Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lynx-aspect-ratio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@seed-design/lynx-react": minor
---

`AspectRatio` 컴포넌트를 추가했습니다. 콘텐츠를 지정한 가로:세로 비율로 유지하며, Lynx의 native `aspect-ratio` CSS를 사용합니다. `overflow`는 기본값이 `hidden`입니다.
4 changes: 4 additions & 0 deletions examples/lynx-spa/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useSafeArea } from "@seed-design/lynx-react";

import { ActionButtonPage } from "./pages/ActionButtonPage.jsx";
import { AppBarPage } from "./pages/AppBarPage.jsx";
import { AspectRatioPage } from "./pages/AspectRatioPage.jsx";
import { BadgePage } from "./pages/BadgePage.jsx";
import { BottomSheetPage } from "./pages/BottomSheetPage.jsx";
import { CheckboxPage } from "./pages/CheckboxPage.jsx";
Expand Down Expand Up @@ -43,6 +44,7 @@ export type Page =
| "theming"
| "action-button"
| "app-bar"
| "aspect-ratio"
| "badge"
| "bottom-sheet"
| "checkbox"
Expand Down Expand Up @@ -79,6 +81,7 @@ function BackButton({ onBack }: { onBack: () => void }) {
// Pages that own their own scroll areas use a fullscreen flex shell.
const FULLSCREEN_PAGES = new Set<Page>([
"action-button",
"aspect-ratio",
"badge",
"bottom-sheet",
"checkbox",
Expand Down Expand Up @@ -140,6 +143,7 @@ export function App(props: { onRender?: () => void }) {
<BackButton onBack={() => setCurrentPage("home")} />
</view>
{currentPage === "action-button" && <ActionButtonPage />}
{currentPage === "aspect-ratio" && <AspectRatioPage />}
{currentPage === "badge" && <BadgePage />}
{currentPage === "bottom-sheet" && <BottomSheetPage />}
{currentPage === "checkbox" && <CheckboxPage />}
Expand Down
93 changes: 93 additions & 0 deletions examples/lynx-spa/src/pages/AspectRatioPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AspectRatio } from "@seed-design/lynx-react";

import { CatalogExamples, CatalogSectionTitle } from "../components/catalog-examples.jsx";
import {
defineVariantAxes,
VariantCatalog,
type VariantCatalogValues,
} from "../components/variant-catalog.jsx";

/** ratio prop은 number이지만, 카탈로그에서 토글하기 쉽도록 사람이 읽는 프리셋으로 노출한다. */
const RATIO_PRESETS: Record<string, number> = {
"1:1": 1,
"4:3": 4 / 3,
"3:2": 3 / 2,
"16:9": 16 / 9,
"3:4": 3 / 4,
"9:16": 9 / 16,
};

/** picsum.photos는 seed별로 고정된 임의 사진을 돌려준다. (네트워크 필요) */
function placeholderSrc(seed: string) {
return `https://picsum.photos/seed/${seed}/1200/900`;
}

const variants = defineVariantAxes([
{
key: "ratio",
options: ["1:1", "4:3", "3:2", "16:9", "3:4", "9:16"],
defaultValue: "4:3",
},
]);

type AspectRatioValues = VariantCatalogValues<typeof variants>;

function renderAspectRatio(values: AspectRatioValues) {
return (
<view className="w-full">
<AspectRatio ratio={RATIO_PRESETS[values.ratio]} width="full">
<image
src={placeholderSrc("seed-design")}
mode="aspectFill"
className="w-full h-full rounded-r2"
/>
</AspectRatio>
</view>
);
}

function ImageRatioCard({ ratio, label, seed }: { ratio: number; label: string; seed: string }) {
return (
<view className="mb-x2">
<text className="t6-regular text-fg-neutral-subtle mb-x1">{label}</text>
<AspectRatio ratio={ratio} width="full">
<image src={placeholderSrc(seed)} mode="aspectFill" className="w-full h-full rounded-r2" />
</AspectRatio>
</view>
);
}

function SolidRatioBox({ ratio, label }: { ratio: number; label: string }) {
return (
<view className="mb-x2">
<text className="t6-regular text-fg-neutral-subtle mb-x1">{label}</text>
<AspectRatio ratio={ratio} width="full">
<view className="w-full h-full flex flex-col items-center justify-center bg-bg-brand-solid rounded-r2">
<text className="t4-bold text-fg-static-white">{label}</text>
</view>
</AspectRatio>
</view>
);
}

function AspectRatioExamples() {
return (
<CatalogExamples title="AspectRatio" gap="16px">
<CatalogSectionTitle>Placeholder image (mode="aspectFill")</CatalogSectionTitle>
<ImageRatioCard ratio={16 / 9} label="16 / 9" seed="landscape" />
<ImageRatioCard ratio={1} label="1 / 1" seed="square" />
<ImageRatioCard ratio={3 / 4} label="3 / 4" seed="portrait" />

<CatalogSectionTitle>Solid fill (자식이 비율 박스를 채우는지)</CatalogSectionTitle>
<SolidRatioBox ratio={4 / 3} label="4 / 3" />
</CatalogExamples>
);
}

export function AspectRatioPage() {
return (
<VariantCatalog variants={variants} examples={<AspectRatioExamples />}>
{(values) => renderAspectRatio(values)}
</VariantCatalog>
);
}
1 change: 1 addition & 0 deletions examples/lynx-spa/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function HomePage({ navigate }: { navigate: (page: Page) => void }) {
<ListItem title="Text" onTap={() => navigate("text-primitive")} />
<ListItem title="ActionButton" onTap={() => navigate("action-button")} />
<ListItem title="AppBar" onTap={() => navigate("app-bar")} />
<ListItem title="AspectRatio" onTap={() => navigate("aspect-ratio")} />
<ListItem title="Badge" onTap={() => navigate("badge")} />
<ListItem title="BottomSheet" onTap={() => navigate("bottom-sheet")} />
<ListItem title="Checkbox" onTap={() => navigate("checkbox")} />
Expand Down
51 changes: 51 additions & 0 deletions packages/lynx-react/src/components/AspectRatio/AspectRatio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import clsx from "clsx";
import * as React from "@lynx-js/react";

import type { LynxPressableProps, LynxStyledElementProps, LynxViewRef } from "../../types";
import { useStyleProps, type StyleProps } from "../../utils/styled";

/**
* @platform Lynx
*
* `AspectRatio`는 콘텐츠를 지정한 가로:세로 비율로 유지하는 레이아웃 primitive입니다.
* `overflow`는 기본적으로 `hidden`이라 비율 박스를 넘는 자식은 잘립니다.
*
* 웹 버전은 iOS 15 미만 호환을 위해 `::before` + `padding-bottom` 트릭을 사용하지만,
* Lynx는 native `aspect-ratio` CSS를 지원하므로 이를 직접 사용합니다. (Lynx는 스타일
* 적용 단계에서 pseudo-element(`::before`)를 지원하지 않습니다.)
*
* radius/stroke 같은 프레임 스타일은 AspectRatio가 아니라 `ImageFrame`이 담당합니다.
*/
export interface AspectRatioProps extends StyleProps, LynxStyledElementProps, LynxPressableProps {
/**
* 가로 / 세로 비율 (width / height)
* @default 4 / 3
*/
ratio?: number;
/** @default "hidden" */
overflowX?: StyleProps["overflowX"];
/** @default "hidden" */
overflowY?: StyleProps["overflowY"];
bindtouchstart?: () => void;
bindtouchend?: () => void;
bindtouchcancel?: () => void;
}

export const AspectRatio = React.forwardRef<unknown, AspectRatioProps>((props, ref) => {
const { ratio = 4 / 3, overflowX = "hidden", overflowY = "hidden", ...rest } = props;
const { style, restProps } = useStyleProps({ ...rest, overflowX, overflowY });
const { children, className, ...nativeProps } = restProps;

return (
<view
{...(ref ? { ref: ref as LynxViewRef } : {})}
{...nativeProps}
className={clsx(className)}
style={{ ...style, aspectRatio: String(ratio) }}
>
{children}
</view>
);
});

AspectRatio.displayName = "AspectRatio";
1 change: 1 addition & 0 deletions packages/lynx-react/src/components/AspectRatio/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./AspectRatio";
1 change: 1 addition & 0 deletions packages/lynx-react/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./ActionButton";
export * from "./AppBar";
export * from "./AspectRatio";
export * from "./Badge";
export * from "./Box";
export * from "./BottomSheet";
Expand Down
Loading