본문으로 건너뛰기

표시

이 페이지는 영어에서 번역되었습니다. 영어 페이지 읽기

상태를 보여 주는 컴포넌트입니다. 메시지, 배지, 진행 표시, 키와 값, 이력, 자리 표시자가 있습니다. 서버에서 렌더링되며 자체 요청이 필요하지 않습니다.

Stack::new()
    .add(Alert::info("Imports run in the background.").dismissible())
    .add(Alert::warning("Three products are low on stock.").title("Stock"))
    .add(Row::new().compact().col(Badge::new("Paid").tone(Tone::Success)).col(Badge::new("Overdue").tone(Tone::Danger)).col(Badge::new("Draft")))
    .add(Progress::new("Import", 64, 100))
    .add(Timeline::new()
        .event(ctx.now().add_ms(-26 * 3_600_000), "Created", None, Tone::Info)
        .event(ctx.now().add_ms(-3_600_000), "Approved", Some(Text::from("by the manager")), Tone::Success))
    .add(Skeleton::lines(2))

정보: Imports run in the background.

경고: Stock

Three products are low on stock.

Paid
Overdue
Draft
Import64%
  1. Created

  2. Approved

    by the manager

불러오는 중…

Alert

Alert::info, success, warning, danger 또는 Alert::new(tone, text)를 사용합니다. .title(text)는 제목을, .dismissible()은 닫기 버튼을 추가합니다. 톤은 메시지 앞에서 음성으로도 전달되므로 색상만으로 전달되는 일이 없습니다.

Badge

Badge::new(text).tone(Tone::...)입니다. 톤은 Neutral, Info, Success, Warning, Danger, Brand입니다. 그리드는 .badge(|v| tone)으로 값을 배지로 표시합니다.

Progress

Progress::new(label, value, max)이며 선택적으로 .tone(...)을 붙일 수 있습니다. 레이블과 값이 있는 실제 progressbar입니다.

DescriptionList

레코드의 필드에서 만들거나 직접 작성하는 키와 값의 쌍입니다.

DescriptionList::of(&invoice)
    .field(Invoice::customer)
    .field(Invoice::issued)
    .item(t!("ui.status"), "INV-2026-0042")

값은 그리드 셀처럼 서식화됩니다. 금액, 날짜, choice가 사용자의 언어로 표시됩니다.

Timeline

Timeline::new().event(moment, title, detail, tone)은 이벤트를 나열합니다. 시각은 사용자의 시간대와 언어로 표시됩니다.

EmptyState와 Skeleton

EmptyState::new(title).text(text).opens(label, screen, params)는 빈 영역을 다음 단계로 채웁니다. Skeleton::lines(n)은 무언가를 불러오는 동안의 자리 표시자입니다.

Breadcrumb::new().link(label, screen, params).current(label): 링크는 타입이 있는 Screen 참조이므로 Screen의 이름이 바뀌어도 깨진 링크가 남지 않습니다.

ActionButton

ActionButton::new(label, action, input)은 action을 실행합니다. .confirm(text)는 먼저 확인을 묻고, .tone(Tone::Danger)는 파괴적인 작업임을 표시하며, .on_success(|result, ui| ...)는 effect를 실행합니다. 입력은 서버에서 고정되며 클라이언트에서 오지 않습니다. action의 권한은 다른 폼과 마찬가지로 서버에서 검사됩니다.

ActionButton::new(t!("docs.live.count"), count_samples, ())
    .on_success(|n, ui| {
        if let Ok(n) = n {
            ui.toast(t!("docs.live.counted", n = n));
        }
    })

Markdown

Markdown::new(text)는 댓글, 제품 설명, 도움말 같은 서식 있는 텍스트를 보여 줍니다. 텍스트는 문자열 또는 로케일 키입니다. markdown 기능(zeyn = { features = ["markdown"] })이 필요하며, 이 기능은 의존성을 추가하지 않습니다.

Markdown::new(product.description.clone())
Markdown::new(t!("help.import")).trusted()
  • 제목, 문단, 목록, 표, 코드 블록, 인용, 가로줄, 인라인 코드, 링크, 이미지, 굵게, 기울임을 지원합니다.
  • 원시 HTML은 절대 통과시키지 않습니다. <script>는 텍스트로 표시되며 코드 블록도 이스케이프됩니다.
  • 링크는 http, https, mailto, 앵커, 상대 경로만 가리킬 수 있습니다. javascript:나 data: 같은 다른 것은 레이블만 남습니다. 사용자 링크에는 rel="noopener noreferrer nofollow ugc"가 붙습니다.
  • 이미지는 앱 자체(상대 경로)에서만 불러오며, 다른 이미지는 대체 텍스트를 표시합니다.
  • .trusted()는 앱이 작성한 텍스트용입니다. 제목에 링크용 id가 붙고 링크에서 nofollow ugc가 빠집니다. 이스케이프와 링크 규칙은 그대로입니다.
  • 64 KB(MAX_MARKDOWN)를 넘는 텍스트는 잘리고 중첩에도 상한이 있으므로, 악의적인 입력도 렌더링 비용이 작게 유지됩니다.

Embed

Embed는 동영상 플레이어나 지도처럼 다른 사이트의 페이지를 잠긴 프레임 안에 보여 줍니다. 제목은 필수입니다(스크린 리더가 제목으로 프레임을 알려 줍니다). .title(...)이 없으면 embed를 배치할 수 없으며 컴파일러가 그렇게 알려 줍니다.

Embed::url("https://www.youtube-nocookie.com/embed/abc")
    .title(t!("tour.title"))
    .allow([EmbedPermission::Fullscreen, EmbedPermission::PictureInPicture])
    .aspect(16, 9)
    .click_to_load("/static/tour.jpg")
  • 호스트는 앱의 frame-src에 있어야 합니다. App::csp(|c| c.frame_src(["https://www.youtube-nocookie.com"])) (보안 참고). 주소가 https가 아니거나 호스트가 목록에 없으면 프레임이 전혀 렌더링되지 않으며, 콘텐츠를 표시할 수 없다는 경고가 나타납니다.
  • 모든 프레임에는 sandbox="allow-scripts allow-same-origin allow-presentation", referrerpolicy="no-referrer", loading="lazy"가 붙습니다. 팝업, 최상위 탐색, 폼, 다운로드는 절대 허용할 수 없습니다. .allow(...)는 브라우저 기능을 추가합니다. Fullscreen, PictureInPicture, Autoplay, EncryptedMedia가 있습니다.
  • .click_to_load(poster)는 먼저 포스터 이미지(앱의 경로 또는 img-src의 호스트, 없으면 "")와 다른 사이트의 이름이 있는 버튼을 보여 줍니다. 버튼을 누르기 전에는 다른 사이트에 접속하지 않으며, 누르면 프레임이 그 자리를 차지하고 포커스를 받습니다. 스크립트는 media.js의 일부이므로 추가 요청이 들지 않습니다.
  • .notice(t!(...))는 콘텐츠 경고처럼 앱의 텍스트를 그 버튼에 표시합니다. 이스케이프되고, 다른 모든 텍스트처럼 번역되며, 버튼의 접근 가능한 이름의 일부이므로 스크린 리더가 사용자가 결정하기 전에 이를 읽습니다. .click_to_load 없이 사용하면 포스터 없는 버튼이 켜집니다.
  • .remember("external-video", Duration::from_secs(24 * 60 * 60))는 페이지마다가 아니라 그 시간 동안 한 번만 묻습니다. 브라우저는 종료 시각(Unix 밀리초)만 localStorage의 zeyn.consent.{scope}에 보관하며, 쿠키는 설정되지 않고 서버는 이를 보지 못하므로 캐시된 공개 페이지는 모두에게 같게 유지됩니다. 이후의 페이지, 앱 내 탐색, 다른 탭은 그 범위의 프레임을 포커스를 옮기지 않고 스스로 불러옵니다(WCAG 2.2 3.2.1 On Focus, 2.4.3 Focus Order). 포커스는 실제로 누른 후에만 프레임으로 이동합니다. 시간이 지났을 때, 저장된 값이 정수가 아니거나 embed의 시간보다 더 먼 미래일 때(직접 바꾼 경우), 저장소가 차단되었을 때, JavaScript가 없을 때 버튼이 다시 나타납니다. 범위는 ASCII 문자, 숫자, -, _로 이루어진 1자에서 40자이고, 시간은 1초에서 30일(MAX_EMBED_CONSENT)입니다. 그 밖의 값은 그렇게 알리는 메시지와 함께 panic을 일으킵니다. 같은 notice를 공유하는 embed는 범위를 공유합니다.
  • embed할 주소를 담는 필드에는 #[rule(embed)]를 붙입니다. 폼은 URL 또는 붙여 넣은 embed 코드를 받아 src만 남기고, https와 목록에 있는 호스트를 요구하며, 추적 매개변수(utm_*, fbclid, gclid 등)를 제거하고, HTML이 아니라 정규 주소를 저장합니다. API 입력은 정규 주소를 보내야 합니다.
#[entity(table = "videos")]
pub struct Video {
    #[key]
    pub id: VideoId,
    #[rule(required, embed)]
    pub url: String,
}

Image

Image에는 대체 텍스트와 크기가 필요합니다. .alt(...)와 .size(width, height)가 없으면 배치할 수 없으며 컴파일러가 그렇게 알려 줍니다. 크기는 파일을 불러오는 동안 공간을 유지하므로 페이지가 움직이지 않습니다.

Image::new("https://images.example.com/logo.png")
    .alt(t!("brand.logo"))
    .size(240, 80)
    .lazy()
    .srcset([("https://images.example.com/logo@2x.png", "2x")])
  • src는 앱의 경로(/static/logo.png) 또는 앱의 img-src 호스트에 있는 https 주소입니다(App::csp(|c| c.img_src([...]))). 그 밖의 이미지는 렌더링되지 않고 대체 텍스트가 표시되므로 브라우저가 차단된 요청을 기록하지 않습니다.
  • 모든 이미지에는 decoding="async"가 붙으며, .lazy()는 화면 아래쪽 이미지에 loading="lazy"를 추가합니다. srcset은 밀도(2x) 또는 너비(800w) 설명자를 받으며, 다른 호스트의 항목이나 다른 설명자를 쓴 항목은 제외됩니다. 너비 설명자는 이미지 너비로 sizes를 추가합니다.
  • 순전히 장식용인 이미지는 빈 대체 텍스트를 사용합니다. .alt(Text::from("")).

ExternalLink::new(url, text)는 rel="noopener noreferrer"로 다른 사이트에 연결하므로, 다른 사이트는 페이지 주소도 창에 대한 핸들도 얻지 못합니다. 스크린 리더는 텍스트 뒤에 "external site"를 읽고, 시각적으로는 화살표가 이를 표시합니다.

ExternalLink::new("https://example.com/help", t!("help.center")).new_tab()
ExternalLink::new(customer.website.clone(), t!("customer.website")).user_content()

.user_content()는 사용자가 입력한 주소에 nofollow ugc를 추가합니다. .new_tab()은 새 탭에서 열고 이를 스크린 리더에 알립니다. 설정하지 않으면 링크는 같은 탭에 머뭅니다. https와 http 주소만 링크가 되며, javascript: 같은 다른 것은 일반 텍스트로 표시됩니다.

툴팁, 팝오버, 사용 중 오버레이

툴팁은 컨트롤 하나에 대한 짧은 설명입니다. ActionButton::tip(text), Screen 툴바의 ToolButton::tip(text), 필드 레이블 뒤에 정보 버튼을 추가하는 Form::tip(field, text)가 있습니다. 텍스트는 컨트롤의 aria-describedby이므로 스크린 리더가 컨트롤과 함께 읽습니다. 잠깐 마우스를 올리면 표시되고 포커스를 받으면 즉시 표시되며, 포인터가 그 위에 있는 동안 유지되고(WCAG 1.4.13), Escape로 포커스를 옮기지 않고 숨길 수 있으며, 터치에서는 길게 누르면(정보 버튼을 탭하면) 표시됩니다. 툴팁은 사용자가 반드시 알아야 하는 정보를 담지 않습니다. 그런 정보는 레이블, 필드의 규칙, 페이지에 넣으십시오.

Popover::new(label).title(title).add(node)는 작은 세부 정보를 위해 옆에 비모달 대화 상자를 여는 버튼입니다. 포커스가 그 안으로 이동하며, Escape, 닫기 버튼, 바깥 클릭으로 닫히고 포커스는 버튼으로 돌아옵니다. 좁은 화면에서는 하단 시트로 열립니다. 자식은 일반 노드이므로 그 안의 폼은 모든 서버 검사와 함께 자신의 노드 경로로 전송됩니다.

Busy::new().add(node)는 그 안에서 요청이 실행되는 동안 내용을 덮습니다. 폼 저장, action 버튼, 그리드나 트리 로딩이 그 예입니다. 컨테이너에는 즉시 aria-busy가 붙고, 스피너와 "Working"이라는 텍스트(상태 메시지)는 400 ms 후 또는 .delay_ms(n) 후에만 표시되므로 빠른 응답에서는 깜박이지 않으며, 한 번 표시되면 최소 300 ms 동안 유지됩니다. 오버레이는 포인터를 막지만 키보드는 막지 않으며 포커스는 있던 자리에 남습니다.

Stack::new()
    .add(ActionButton::new(t!("docs.live.count"), count_samples, ()).tip(t!("docs.live.count_tip")))
    .add(Popover::new(t!("docs.live.rules")).title(t!("docs.live.rules_title")).add(rules_list))
    .add(Busy::new().add(ActionButton::new(t!("docs.live.slow"), slow_count, ())))
키위치동작
Tab툴팁이 있는 컨트롤포커스가 툴팁을 표시하고, 벗어나면 숨깁니다
Escape툴팁이 표시된 상태의 어디서나툴팁을 숨깁니다. 포커스는 유지됩니다
Enter, Space팝오버 버튼팝오버를 엽니다. 포커스가 첫 번째 컨트롤로 이동합니다
Escape팝오버 안닫습니다. 포커스가 버튼으로 돌아옵니다
Tab팝오버 안컨트롤 사이를 이동합니다. 벗어나면 닫힙니다

세 가지를 합쳐 gzip 기준 약 2.2 KB의 스크립트가 필요하고 자체 요청은 없습니다. 툴팁은 노드 1개, 팝오버는 6개와 그 내용, 사용 중 오버레이는 3개입니다.

MenuButton::new(label)은 명령 메뉴를 엽니다. MenuButton::split(label, action, input)은 주 부분에서 action 하나를 실행하고 화살표에서 메뉴를 엽니다. 명령은 .action(label, action, input)(또는 성공 후 effect를 실행하는 .action_then(...)), 창 열기 같은 effect를 위한 .item(label, effect), .separator()입니다. .confirm(text)는 마지막 action 명령을 실행하기 전에 확인을 묻습니다.

MenuButton::split(t!("orders.save"), save_draft, id)
    .action(t!("orders.save_and_send"), save_and_send, id)
    .separator()
    .action(t!("orders.delete"), delete_order, id)
    .confirm(t!("orders.delete_confirm"))

action 명령은 자체 경로를 가진 노드이며 ActionButton처럼 폼 엔드포인트로 전송됩니다. Screen의 접근 권한, Feature의 가드, action의 권한, 인터셉터, CSRF, 쓰기 속도 제한이 호출할 때마다 검사되고 입력은 서버에 남습니다. 사용자가 실행할 수 없는 명령은 메뉴에서 빠지며(분할 버튼의 주 action은 대신 비활성화됩니다), 호출되더라도 서버가 거부합니다.

키위치동작
Enter, Space, 아래쪽 화살표버튼첫 번째 명령에서 메뉴를 엽니다
위쪽 화살표버튼마지막 명령에서 메뉴를 엽니다
아래쪽 화살표, 위쪽 화살표메뉴다음 명령과 이전 명령으로 이동하며 순환합니다
Home, End메뉴첫 번째와 마지막 명령
문자메뉴그 문자로 시작하는 다음 명령
Enter, Space명령명령을 실행하고 메뉴를 닫습니다
Escape메뉴닫습니다. 포커스가 버튼으로 돌아옵니다
Tab메뉴닫고 다음으로 이동합니다

메뉴에는 gzip 기준 약 0.8 KB의 스크립트가 필요하며 명령이 실행되기 전까지 요청이 없습니다. 노드는 4개에 명령당 2개입니다.