본문으로 건너뛰기

PlButton

액션을 실행하는 컨트롤입니다. 사용자가 의도적으로 일으키는 모든 것에 씁니다: 폼 제출, 저장, 삭제.

React
tsx
import { PlButton } from 'plass-ui';

<PlButton onClick={save}>Save</PlButton>;
dart
import 'package:plass_ui/plass_ui.dart';

PlButton(onPressed: save, child: const Text('Save'));

Props

Prop타입기본값설명
variant공통'solid' | 'glass' | 'ghost''solid'표면의 재질. solid는 hue가 도는 그러데이션 유리판, glass는 맑은 시트, ghost는 표면 없음
size공통'xs' | 'sm' | 'md' | 'lg' | 'xl''md'높이와 타입 스케일. xs 24px · sm 32px · md 40px · lg 48px · xl 56px
color공통'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'의미론적 색 역할. 임의 색상값은 받지 않습니다
density공통'default' | 'compact''default'여백만 바꿉니다. 높이와 글자 크기는 그대로
elevation공통0 | 1 | 2 | 31그림자 깊이. 컨트롤은 시트 위에 놓이므로 기본값이 1입니다. 호버는 한 단계 올리고, 누르면 한 단계 내려 시트에 닿습니다
startIconReactNode라벨 앞에 놓이는 내용. 1.2em으로 그려져 라벨 크기를 따라갑니다
endIconReactNode라벨 뒤에 놓이는 내용
loadingbooleanfalsestartIcon 자리에 스피너를 띄우고 활성화를 막습니다. 포커스는 유지됩니다
readOnlybooleanfalse비활성이되 흐려지지 않음. 액션은 존재하지만 여기서는 쓸 수 없다는 뜻
disabledbooleanfalse사용 불가. 빛과 그림자를 잃고 페이지가 비쳐 보이며, 포커스 순서에서 빠집니다
fullWidthbooleanfalse컨테이너 너비만큼 확장
renderuseRender.RenderPropbutton 대신 다른 요소로 렌더링합니다 (<a href>, 라우터의 Link). 링크는 링크로 남아 크롤러와 스크린리더가 그대로 인식합니다
childrenReactNode라벨. 생략하면 정사각형 아이콘 버튼이 됩니다
Prop타입기본값설명
variant공통PlassVariant?PlassVariant.solid표면의 재질. solid는 hue가 도는 그러데이션 유리판, glass는 맑은 시트, ghost는 표면 없음
size공통PlassSize?PlassSize.md높이와 타입 스케일. xs 24px · sm 32px · md 40px · lg 48px · xl 56px
color공통PlassColor?PlassColor.primary의미론적 색 역할. 임의 색상값은 받지 않습니다
density공통PlassDensity?PlassDensity.standard여백만 바꿉니다. 높이와 글자 크기는 그대로
elevation공통int?1그림자 깊이. 컨트롤은 시트 위에 놓이므로 기본값이 1입니다. 호버는 한 단계 올리고, 누르면 한 단계 내려 시트에 닿습니다
startIconWidget?라벨 앞에 놓이는 내용. 1.2em으로 그려져 라벨 크기를 따라갑니다
endIconWidget?라벨 뒤에 놓이는 내용
loadingboolfalsestartIcon 자리에 스피너를 띄우고 활성화를 막습니다. 포커스는 유지됩니다
readOnlyboolfalse비활성이되 흐려지지 않음. 액션은 존재하지만 여기서는 쓸 수 없다는 뜻
disabledbool?false사용 불가. 빛과 그림자를 잃고 페이지가 비쳐 보이며, 포커스 순서에서 빠집니다
fullWidthboolfalse컨테이너 너비만큼 확장
onPressedVoidCallback?눌렸을 때. null이면 Flutter 관례대로 disabled와 같이 취급합니다
onLongPressVoidCallback?길게 눌렀을 때. 웹의 contextmenu에 대응하는 자리
focusNodeFocusNode?포커스를 밖에서 제어할 때 넘깁니다. 없으면 버튼이 스스로 하나 만듭니다
autofocusboolfalse화면에 올라오면서 포커스를 가져갑니다
semanticLabelString?스크린 리더가 읽을 이름. 아이콘만 있는 버튼에는 반드시 넣어야 합니다
childWidget?라벨. 생략하면 정사각형 아이콘 버튼이 됩니다

네이티브 <button> 속성은 그대로 전달됩니다. 예외는 color 하나로, 위 표의 color와 이름이 겹쳐서 제외했습니다.

PlButton은 트리 위쪽에 아무것도 필요하지 않습니다. PlassTheme이 없으면 플랫폼의 밝기를 따라가므로, 어느 앱에 그냥 놓아도 이미 맞는 테마입니다. 넘어오지 않는 것들은 React 빌드와 다른 점에 있습니다.

공통 축(variant size color density elevation)이 라이브러리 전체에서 뜻하는 것은 prop 규칙에 있습니다.

Examples

variant

solid는 색이 들어간 유리판이자 주요 액션입니다. glass는 hairline을 두른 맑은 시트로, 보조 액션에 씁니다. ghost는 포인터가 올라오기 전까지 표면이 없어서 툴바나 행에 어울립니다. 화면당 solid는 하나로 유지하세요.

glass 버튼은 색 계열을 글자에 두르므로, color="secondary"color: PlassColor.secondary가 네 번째 variant가 아니라 조용한 중립 버튼이 됩니다.

셋 다 interaction light를 가집니다. 포인터를 따라 컨트롤 위를 옮겨 다니는 부드러운 빛과, 누를 때 한 단계 밝게 터진 뒤 약 700ms에 걸쳐 빠져나가는 flash입니다. 터치 화면에서는 버튼 위를 끄는 손가락을 따라갑니다. 빛은 solid에서는 흰색이고, 나머지 둘에서는 그 계열의 tint입니다.

React

color

여섯 가지 역할 색만 받습니다. 임의 색상값은 받지 않습니다. solid에서는 계열이 그러데이션과 그 아래 그림자이고, glassghost에서는 라벨입니다.

React

size

높이와 타입 스케일을 함께 정합니다. xs 24px · sm 32px · md 40px · lg 48px · xl 56px. md가 데스크톱 기본값이고, lgxl은 모두 모바일 터치 타깃 44px을 넘깁니다.

React

density

density는 좌우 여백만 바꿉니다. 같은 size의 두 버튼은 density와 무관하게 높이가 같아서, 섞어 놓은 줄도 기준선을 유지합니다.

기본 트랙의 이름은 PlassDensity.standard입니다. React 패키지에서는 'default'인데, Dart에서 default는 예약어입니다. 공통 어휘 중에서 두 패키지가 다르게 부르는 값은 이것 하나뿐입니다.

React

startIcon과 endIcon

아이콘은 1.2em으로 그려져 라벨을 따라가므로 따로 크기를 줄 필요가 없습니다. 아이콘만 있고 childrenchild가 없으면 버튼은 정사각형이 되고, 그때는 aria-labelsemanticLabel이 필요합니다.

크기는 IconTheme으로 전달되며 Icon은 이것을 알아서 읽습니다. 다른 방식으로 그린 글리프라면 아래 데모처럼 IconTheme.of(context)를 읽으면 됩니다.

React

loading · readOnly · disabled

prop겉모습Focus네이티브 disabled
loading그대로. startIcon 자리에 스피너유지아니오
readOnly색은 유지, 평평해지고 채도가 빠짐유지아니오
disabled빛과 그림자를 잃고 페이지가 비쳐 보임잃음
파라미터겉모습Focus
loading그대로. startIcon 자리에 스피너유지
readOnly색은 유지, 평평해지고 채도가 빠짐유지
disabled빛과 그림자를 잃고 페이지가 비쳐 보임잃음

셋 다 사용 불가로 읽히고, 포커스 순서에서 빠지는 것은 disabled뿐입니다. Flutter에는 aria-busy에 해당하는 것이 없어서 스크린 리더는 loadingreadOnly를 구분하지 못합니다. 화면에서 그 차이가 중요하다면 semanticLabel에 담으세요.

onPressed를 비워 두면 disabled: true와 같습니다. Flutter 개발자가 먼저 손이 가는 쪽이기 때문입니다.

셋 다 탭이 부모로 올라가지 않습니다.

React

elevation

그림자 깊이입니다. 기본값은 0이 아니라 1입니다. 키는 시트 위에 놓이기 때문입니다. 호버는 한 단계를 올리고 누르면 한 단계를 내려서, 기본 버튼은 손가락 아래에서 유리에 딱 닿는 데까지 내려갑니다.

solid 버튼이 자기 색으로 드리우는 tint된 그림자는 이 사다리의 일부가 아니며 함께 커지지도 않습니다. elevation은 표면이 페이지에서 얼마나 떠 있는지를 말할 뿐이고, 한 단계 높은 danger 버튼이 더 붉은 유리판은 아니기 때문입니다.

React

fullWidth

컨테이너 너비만큼 늘어납니다.

React

render

<button> 대신 다른 요소로 렌더링합니다. 이동하는 액션은 <a href>여야 합니다. 크롤러가 따라가고, 스크린 리더의 링크 목록에 들어가며, 새 탭으로 열기나 주소 복사 같은 브라우저 자체 동작이 계속 작동합니다. 라우터의 Link도 같은 방식으로 넣습니다.

표면과 크기, 눌림의 signature는 그대로입니다. <a>에는 disabled가 없으므로, 사용 불가 상태가 되어야 하는 버튼은 <button>으로 남습니다.

React

Accessibility

  • 기본적으로 네이티브 <button>을 렌더링합니다. type이 그대로 전달되므로 폼 안에서 type="submit"이 동작합니다.
  • render로 요소를 바꿔도 그 요소의 semantics는 유지됩니다. <a href>role="button"에 덮이지 않고 링크로 남습니다.
  • 아이콘만 있는 버튼에는 aria-label을 주세요.
  • focus ring은 :focus-visible에서만 나타나므로 마우스 클릭으로는 그려지지 않습니다.
  • loadingreadOnly는 focus를 유지합니다. tab 순서에서 빠지면 키보드 사용자는 페이지에서 자기 위치를 잃습니다.
  • 그러데이션의 두 끝이 모두 그 위의 라벨에 대해 4.5:1을 만족합니다.
  • interaction light는 장식입니다. 어떤 상태도 담지 않으며, 무엇에 대해서도 유일한 신호가 아닙니다. prefers-reduced-motion에서는 easing이 멈춥니다.
  • 활성 여부와 무관하게 버튼으로 읽히며, 이름은 child에서 가져옵니다.
  • 아이콘만 있는 버튼에는 semanticLabel을 주세요.
  • focus ring은 CSS가 :focus-visible이라 부르는 경우에만 나타납니다. 키보드로 도달했을 때만이고, 포인터로 클릭했을 때는 그려지지 않습니다. Flutter에서 같은 구분을 하는 것이 FocusableActionDetector의 focus highlight입니다.
  • Enter, Space, 그리고 숫자패드 Enter로 활성화됩니다. 버튼 자신에 바인딩되어 있어서 위에 앱 위젯이 있든 없든 동작이 같습니다.
  • loadingreadOnly는 focus를 유지합니다. 포커스 순서에서 빠지면 키보드 사용자는 페이지에서 자기 위치를 잃습니다.
  • 그러데이션의 두 끝이 모두 그 위의 라벨에 대해 4.5:1을 만족합니다.
  • interaction light는 장식입니다. 어떤 상태도 담지 않으며, 무엇에 대해서도 유일한 신호가 아닙니다. 애니메이션을 끈 플랫폼(MediaQuery.disableAnimations)에서는 easing이 멈춥니다.

React 빌드와 다른 점

위의 내용은 두 패키지에서 모두 같습니다. 아래는 같지 않은 지점과 그 이유입니다.

ReactFlutter이유
renderFlutter에는 다형 엘리먼트가 없습니다. 이동하는 액션은 onPressed에서 라우터를 호출하세요.
className, style, 네이티브 속성전달할 클래스 목록도 style 속성도 없습니다. 대신 focusNode, autofocus, onLongPress가 있습니다.
onClickonPressedFlutter의 이름입니다. onPressed: null은 Flutter 어디서나 그렇듯 버튼을 비활성으로 만듭니다.
childrenchildFlutter의 이름입니다.
aria-labelsemanticLabelFlutter의 이름입니다.
density="default"PlassDensity.standardDart에서 default는 예약어입니다.
prefers-reduced-motionMediaQuery.disableAnimations플랫폼 자신의 신호입니다.

API는 아니지만 눈에 보이는 차이가 둘 더 있습니다.

  • 폰트. 두 패키지 모두 폰트를 지정하지 않습니다. 버튼은 호스트가 쓰는 폰트를 그대로 물려받고, 폰트를 공급하는 것은 양쪽 다 앱의 몫입니다. 이 페이지의 React 미리보기는 문서 사이트의 UI 폰트로, Flutter 미리보기는 갤러리가 싣고 있는 Inter로 그려집니다. 서체가 둘일 뿐 같은 버튼입니다.

    들리는 것보다 중요한 이야기입니다. 라벨이 weight 600인데 모든 폰트에 600이 있지는 않기 때문입니다. Flutter 엔진이 들고 다니는 페이스는 Roboto Regular 하나뿐이고 나머지 weight는 획을 굵혀서 합성합니다. Roboto 자체도 400, 500, 700이라 600이 없습니다. 그래서 진짜 SemiBold가 없는 폰트를 쓰는 앱에서는 라벨이 위 미리보기보다 두껍고 눈에 띄게 뭉개져 보입니다. Inter, Pretendard, SF, Noto Sans에는 600이 있고 Roboto에는 없습니다.

  • 블러. glass는 뒤에 그려진 것을 블러하는데, Flutter에서 그것은 같은 앱 안을 뜻합니다. 여기 미리보기는 iframe이므로 갤러리가 페이지의 배경을 직접 그립니다. Flutter 미리보기의 glass 버튼 앞에 무언가가 있는 이유가 그것입니다.

나머지는 전부 의도적으로 맞췄습니다. 같은 숫자를 쓰면 오히려 틀렸을 곳까지 포함해서. 그림자 블러는 CSS의 반지름을 Flutter의 시그마로 변환해 두 그림자의 크기가 같도록 했고, solid의 그러데이션은 대각선이 아니라 linear-gradient(135deg, …)가 하는 방식으로 끝점을 계산합니다. 가로로 긴 버튼에서는 눈에 띄게 다른 sweep이기 때문입니다.

Released under the MIT License