Accordion | CoUI
๋ณธ๋ฌธ์œผ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
LogoCoUI

Accordion

์•„์ฝ”๋””์–ธ/์ ‘๊ธฐ ์ปดํฌ๋„ŒํŠธ

๋ชฉ์ฐจ

์ฝ˜ํ…์ธ ๋ฅผ ์ ‘์—ˆ๋‹ค ํŽผ ์ˆ˜ ์žˆ๋Š” ์•„์ฝ”๋””์–ธ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ• (Basic Usage)#

// ๊ธฐ๋ณธ ์•„์ฝ”๋””์–ธ
Accordion(
  items: [
    AccordionItem(
      title: '์„น์…˜ 1',
      content: Text('์„น์…˜ 1์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
    ),
    AccordionItem(
      title: '์„น์…˜ 2',
      content: Text('์„น์…˜ 2์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
    ),
    AccordionItem(
      title: '์„น์…˜ 3',
      content: Text('์„น์…˜ 3์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
    ),
  ],
)

// ๋‹ค์ค‘ ์—ด๊ธฐ ํ—ˆ์šฉ
Accordion(
  allowMultiple: true,
  items: accordionItems,
)

// ์ดˆ๊ธฐ ์—ด๋ฆผ ์ƒํƒœ โ€” ํ•ญ๋ชฉ๋ณ„ expanded
Accordion(
  items: [
    AccordionItem(
      title: '๊ธฐ๋ณธ ์ •๋ณด',
      content: Text('๊ธฐ๋ณธ ์ •๋ณด ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
      expanded: true,
    ),
    AccordionItem(
      title: '์ถ”๊ฐ€ ์ •๋ณด',
      content: Text('์ถ”๊ฐ€ ์ •๋ณด ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
    ),
  ],
)
// ๊ธฐ๋ณธ ์•„์ฝ”๋””์–ธ
Accordion(
  items: [
    AccordionItem(
      title: '์„น์…˜ 1',
      content: Text('์„น์…˜ 1์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
    ),
    AccordionItem(
      title: '์„น์…˜ 2',
      content: Text('์„น์…˜ 2์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
    ),
    AccordionItem(
      title: '์„น์…˜ 3',
      content: Text('์„น์…˜ 3์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
    ),
  ],
)

// ๋‹ค์ค‘ ์—ด๊ธฐ ํ—ˆ์šฉ
Accordion(
  allowMultiple: true,
  items: accordionItems,
)

// ์ดˆ๊ธฐ ์—ด๋ฆผ ์ƒํƒœ โ€” ํ•ญ๋ชฉ๋ณ„ expanded
Accordion(
  items: [
    AccordionItem(
      title: '๊ธฐ๋ณธ ์ •๋ณด',
      content: Text('๊ธฐ๋ณธ ์ •๋ณด ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
      expanded: true,
    ),
    AccordionItem(
      title: '์ถ”๊ฐ€ ์ •๋ณด',
      content: Text('์ถ”๊ฐ€ ์ •๋ณด ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.'),
    ),
  ],
)

Live Preview#

์‚ฌ์šฉ ์‹œ๊ธฐ (When to Use)#

์ด ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”:

  • FAQ, ๋„์›€๋ง ๋“ฑ ์งˆ๋ฌธ-๋‹ต๋ณ€ ํ˜•ํƒœ์˜ ์ฝ˜ํ…์ธ ๋ฅผ ํ‘œ์‹œํ•  ๋•Œ
  • ๊ธด ๋‚ด์šฉ์„ ์„น์…˜๋ณ„๋กœ ์ ‘์–ด์„œ ๊ณต๊ฐ„์„ ์ ˆ์•ฝํ•  ๋•Œ
  • ์„ค์ • ํŒจ๋„์˜ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์˜ต์…˜์„ ๊ทธ๋ฃนํ™”ํ•  ๋•Œ

๋Œ€์‹  ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”:

  • Tree: ๊ณ ์ • ์ฝ˜ํ…์ธ  ์„น์…˜์ด ์•„๋‹ˆ๋ผ ๋ฐ์ดํ„ฐ์˜ ๋ถ€๋ชจยท์ž์‹ ๊ฐ€์ง€๋ฅผ ํƒ์ƒ‰ํ•ด์•ผ ํ•  ๋•Œ
  • Collapsible: ํ•ญ๋ชฉ ๋ชฉ๋ก์˜ ์ƒํ˜ธ๋ฐฐํƒ€ ์—ด๋ฆผ ์—†์ด, ํ•œ ์˜์—ญ์˜ expanded ๋ฅผ ํ˜ธ์ถœ๋ถ€์—์„œ ์ œ์–ดํ•ด์•ผ ํ•  ๋•Œ
  • Disclosure: ํŠธ๋ฆฌ๊ฑฐ ยท ์š”์•ฝ ยท ์ถ”๊ฐ€ ๋‚ด์šฉ ์Šฌ๋กฏ์„ ๊ฐ–๋Š” ๋…๋ฆฝ์ ์ธ ์ •๋ณด ๊ณต๊ฐœ๊ฐ€ ํ•„์š”ํ•  ๋•Œ
  • Tabs: ํŽผ์นจ ๋ชฉ๋ก์ด ์•„๋‹ˆ๋ผ ์„ ํƒ๋œ ํŒจ๋„ ํ•˜๋‚˜๋งŒ ๊ต์ฒดํ•ด์•ผ ํ•  ๋•Œ
  • Dialog / Drawer: ๋ณธ๋ฌธ ํ๋ฆ„ ์•ˆ์˜ ์ ‘ํž˜ ์˜์—ญ์ด ์•„๋‹ˆ๋ผ ์˜ค๋ฒ„๋ ˆ์ด๊ฐ€ ํ•„์š”ํ•  ๋•Œ

Props / Parameters#

Accordion ํŒŒ๋ผ๋ฏธํ„ฐ#

์†์„ฑํƒ€์ž…๊ธฐ๋ณธ๊ฐ’์„ค๋ช…
items List<W> ํ•„์ˆ˜ ์•„์ฝ”๋””์–ธ ํ•ญ๋ชฉ ๋ชฉ๋ก (AccordionItem)
allowMultiple bool false ๋™์‹œ์— ์—ฌ๋Ÿฌ ํ•ญ๋ชฉ ์—ด๊ธฐ ํ—ˆ์šฉ
accordionStyle CoreAccordionStyle? null ์ธ์Šคํ„ด์Šค ์Šคํƒ€์ผ (Style ์‹œ์Šคํ…œ ์ฐธ์กฐ)

AccordionItem#

W ๋Š” Flutter์˜ Widget, Web์˜ Jaspr Component ์ž…๋‹ˆ๋‹ค. ์Šฌ๋กฏ์˜ ์˜๋ฏธ์™€ ํ•„์ˆ˜ ์—ฌ๋ถ€๋Š” ๊ณตํ†ต์ž…๋‹ˆ๋‹ค.

์†์„ฑํƒ€์ž…๊ธฐ๋ณธ๊ฐ’์„ค๋ช…
contentWํ•„์ˆ˜ํŽผ์นจ ๋‚ด์šฉ
titleString?null๊ธฐ๋ณธ ํ—ค๋” ์ œ๋ชฉ
trigger W? null ์ปค์Šคํ…€ ํ—ค๋”. title ๋˜๋Š” trigger ์ค‘ ํ•˜๋‚˜๋Š” ํ•„์ˆ˜
expanded bool false ์ตœ์ดˆ ํŽผ์นจ ์ƒํƒœ. ์™ธ๋ถ€ ์ œ์–ด ๊ฐ’์ด๋‚˜ ๋ณ€๊ฒฝ ์ฝœ๋ฐฑ์ด ์•„๋‹˜

AccordionTrigger (Flutter)#

์†์„ฑํƒ€์ž…๊ธฐ๋ณธ๊ฐ’์„ค๋ช…
childWidgetํ•„์ˆ˜ํ† ๊ธ€ ๋ฐฐ์„ ์„ ์ƒ์†ํ•˜๋Š” ์ปค์Šคํ…€ ํ—ค๋” ๋‚ด์šฉ

์Šคํƒ€์ผ ์‹œ์Šคํ…œ (Style System)#

Accordion ์˜ ๋ชจ๋“  chrome / dimensional / nested-slot ์˜ค๋ฒ„๋ผ์ด๋“œ๋Š” CoreAccordionStyle ๋‹จ์ผ ์Šฌ๋กฏ์œผ๋กœ ํ๋ฆ…๋‹ˆ๋‹ค. ์‹œ๋งจํ‹ฑ enum / behaviour (items / allowMultiple) ๋Š” ์œ„์ ฏ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ง์ ‘ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

์‹œ๋งจํ‹ฑ vs ์Šคํƒ€์ผ#

  • ์‹œ๋งจํ‹ฑ enum / behaviour: ์œ„์ ฏ/์ปดํฌ๋„ŒํŠธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ง์ ‘ (items, allowMultiple)
  • chrome / dimensional / ์Šฌ๋กฏ ์Šคํƒ€์ผ: CoreAccordionStyle ํ•œ ๊ณณ์œผ๋กœ (backgroundColor / borderColor / borderWidth / borderRadius / contentPadding / itemSpacing / dividerColor / dividerThickness / duration / arrowIconStyle / triggerStyle / contentTextStyle / chevronIconStyle / iconGapStyle / clickableStyle)

Resolve chain#

design system default for accordion
  โ†’ CoreAccordionTheme.style                   // ํ”„๋กœ์ ํŠธ ๊ณตํ†ต
  โ†’ parent component slot override
  โ†’ widget.accordionStyle                      // ์ธ์Šคํ„ด์Šค๋ณ„

๊ฐ nested ์Šฌ๋กฏ ์Šคํƒ€์ผ (triggerStyle / contentTextStyle / chevronIconStyle) ์€ ์ž๊ธฐ ์ปดํฌ๋„ŒํŠธ์˜ ์ž์ฒด resolve chain ์œผ๋กœ ๋‹ค์‹œ ํ•œ ๋ฒˆ ๋จธ์ง€๋ฉ๋‹ˆ๋‹ค.

CoreAccordionStyle ํ•„๋“œ#

ํ•„๋“œํƒ€์ž…์„ค๋ช…
duration Duration? Expand / collapse animation duration. Visual transition timing โ€” chrome category, lives on the style not the widget.
arrowIconStyle CoreIconStyle? Chevron / disclosure icon style override. size defers to [defaultArrowIconStyle].
backgroundColor CoreColor? Container background colour for an expanded item. Deliberately has no default* โ€” absence is the design. The Web resolver adds the bg-* class and its inline fragment only inside if (merged.backgroundColor != null) , so a null here is what leaves the accordion transparent over whichever surface hosts it. A constant would turn that branch permanently on and give every accordion in every tree an opaque panel of its own โ€” and only on one platform, because the Flutter widget never reads the resolved value (it paints no container fill at all, so seat (4) of the 1:1:1:1:1:1 mapping is empty for this field).
borderColor CoreColor? Outer border stroke colour. Deliberately has no default* โ€” absence is the design. The Web resolver emits the border colour only inside if (merged.borderColor != null) , and that emit sets border-color alone โ€” width comes from the separate [borderWidth] override, whose own default is stroke0 ([defaultBorderWidth]). A colour constant would therefore put a border-* class on every accordion to describe a stroke that has no width to draw it. The outline this component ships is the between-items rule, not a box around the whole list.
borderWidth double? Outer border stroke width (logical px).
borderRadius CoreBorderRadius? Outer border radius. Deliberately has no default* โ€” absence is the design. The Web resolver writes border-radius only inside if (merged.borderRadius != null) . Square is correct for the shipped accordion: it has no outer fill and no outer stroke ([borderColor] / [defaultBorderWidth] above), so there is no edge for a radius to round. A constant would turn that branch permanently on, so the rule would ship on every accordion and start rounding the moment a caller sets [backgroundColor] or [borderColor] โ€” a corner nobody asked for, arriving with an unrelated override.
contentPadding CoreEdgeInsets? Padding applied to expanded content.
itemSpacing double? Vertical spacing between accordion items (logical px) โ€” native Column.spacing (Flutter) / row-gap (Web). When null items share a divider line instead of a gap. Deliberately has no default* โ€” absence is the design, and it is load-bearing on both platforms. Being null is what selects the divider-separated layout: the Flutter widget branches on itemSpacing != null to decide between Column.spacing and interleaved Divider s, and the Web resolver only switches the container to display:flex; flex-direction:column; row-gap inside if (merged.itemSpacing != null) . A constant would delete the separator from every accordion that never asked for gaps โ€” the two layouts are alternatives, so a default here is a default for the choice, not for a measurement.
dividerColor CoreColor? Divider line colour between items. Deliberately has no default* โ€” absence delegates to the child. Both platforms treat null as "let the composed Divider supply its own colour": Flutter renders a bare const Divider unless [dividerColor] or [dividerThickness] is set, and the Web resolver returns a null divider chrome for the same condition so the widget falls back to the Divider component instead of a raw <div> . A constant would flip that pair of branches and, being equal to CoreDividerStyle.defaultColor , would restate the child's own default here โ€” the flat-duplication half of "ํ•ฉ์„ฑ = ์žฌ๊ตฌํ˜„ ๊ธˆ์ง€".
dividerThickness double? Divider line thickness (logical px). Deliberately has no default* โ€” same delegation as [dividerColor]. It is the other half of the one condition both platforms test ( dividerColor == null && dividerThickness == null ), so a constant on either field alone is enough to swap the composed Divider for a hand-built rule on every accordion, and its value would restate CoreDividerStyle.defaultThickness .
triggerStyle CoreButtonStyle? Trigger row chrome. Each rendered trigger uses Button(variant: ghost or accordion) internally; this slot tweaks chrome (paddingH / labelStyle / leadingIconStyle).
contentTextStyle CoreTextStyle? Expanded content text style (applied when the content is a plain string).
chevronIconStyle CoreIconStyle? Chevron / disclosure icon style (the rotating indicator).
iconGapStyle CoreGapStyle? 1-off spacer between the trigger label and the chevron icon โ€” rendered by Gap (Flutter) / inline gap CSS (Web). Defers to [defaultIconGapStyle].
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed trigger Clickable (press scale / focus ring / cursor / keyboard activation). Merged on top of [defaultClickableStyle] and raw-forwarded โ€” the Clickable's own resolver fills the rest.

์‚ฌ์šฉ ์˜ˆ (Flutter)#

Accordion(
  items: [
    AccordionItem(
      title: 'FAQ Question',
      content: Text('Answer'),
    ),
  ],
  accordionStyle: CoreAccordionStyle(
    contentPadding: CoreEdgeInsets.symmetric(vertical: CoreSpace.space24),
    dividerColor: CoreColor.token(CoreColors.outlineVariant),
    dividerThickness: CoreStrokeWidth.stroke1,
    triggerStyle: CoreButtonStyle(
      padding: CoreEdgeInsets.symmetric(vertical: CoreSpace.space16),
      labelStyle: CoreTextStyle.token(CoreTextStyles.bodyLarge),
    ),
    contentTextStyle: CoreTextStyle.token(CoreTextStyles.bodySmall),
    chevronIconStyle: CoreIconStyle(size: CoreIconSize.size20),
  ),
)

์‚ฌ์šฉ ์˜ˆ (Web)#

Accordion(
  items: [
    AccordionItem(
      title: 'FAQ Question',
      content: Text('Answer'),
    ),
  ],
  accordionStyle: CoreAccordionStyle(
    contentPadding: CoreEdgeInsets.symmetric(vertical: CoreSpace.space24),
    dividerThickness: CoreStrokeWidth.stroke1,
    triggerStyle: CoreButtonStyle(
      padding: CoreEdgeInsets.symmetric(vertical: CoreSpace.space16),
    ),
  ),
)

๋น ๋ฅธ ์˜ค๋ฒ„๋ผ์ด๋“œ (Chain)#

์ด๋ฏธ ๋งŒ๋“  Accordion ์ธ์Šคํ„ด์Šค์— ์Šคํƒ€์ผ์„ ๋น ๋ฅด๊ฒŒ ๋ง๋ถ™์ด๊ณ  ์‹ถ๋‹ค๋ฉด withStyle ์ฒด์ธ์„ ์“ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ƒ์„ฑ์ž์˜ style ์Šฌ๋กฏ ์ธ์ž์™€ ๋™์ผํ•˜๊ฒŒ ๋™์ž‘ํ•˜์ง€๋งŒ, ์ด๋ฏธ ๊ตฌ์„ฑ๋œ ์œ„์ ฏ ์œ„์—์„œ ๋ฐ”๋กœ ์ด์–ด ์“ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. .radius4์ฒ˜๋Ÿผ Core ํ† ํฐ ์ƒ์ˆ˜ ์ด๋ฆ„๊ณผ ๋˜‘๊ฐ™์€ ์ด๋ฆ„์˜ getter๋„ ์žˆ์Šต๋‹ˆ๋‹ค โ€” withStyle์„ ํ•œ ๋ฒˆ ๋” ์ค„์ธ sugar๋กœ, ์ด๋ฆ„์ด ๊ณง ๊ฐ’์ด๋ผ(radius4 == CoreRadius.radius4) ์–ด๋А ์ปดํฌ๋„ŒํŠธ์—์„œ ์จ๋„ ๋œป์ด ๊ฐˆ๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

class AccordionChainExample extends StatelessWidget {
  const AccordionChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // ํ† ํฐ-์ •ํ™• getter combo โ€” ์ด๋ฆ„์ด ๊ณง ๊ฐ’ (getter ์ด๋ฆ„ = Core ํ† ํฐ ์ƒ์ˆ˜ 1:1).
        const Accordion(
          items: [
            AccordionItem(
              title: 'Is it accessible?',
              content: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
              expanded: true,
            ),
            AccordionItem(
              title: 'Is it styled?',
              content: Text(
                'Yes. It comes with default styles that matches the other components.',
              ),
            ),
            AccordionItem(
              title: 'Is it animated?',
              content: Text(
                "Yes. It's animated by default, but you can disable it if you prefer.",
              ),
            ),
          ],
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control โ€” getter ๊ฐ€ ์—†๋Š” ํ•„๋“œ(borderColor/borderWidth)๊นŒ์ง€.
        const Accordion(
          items: [
            AccordionItem(
              title: 'Is it accessible?',
              content: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
              expanded: true,
            ),
            AccordionItem(
              title: 'Is it styled?',
              content: Text(
                'Yes. It comes with default styles that matches the other components.',
              ),
            ),
            AccordionItem(
              title: 'Is it animated?',
              content: Text(
                "Yes. It's animated by default, but you can disable it if you prefer.",
              ),
            ),
          ],
        ).withStyle(
          const CoreAccordionStyle(
            backgroundColor: CoreColor.token(
              CoreColors.surfaceContainerHighest,
            ),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderWidth: CoreStrokeWidth.stroke2,
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
          ),
        ),
      ],
    );
  }
}
class AccordionChainExample extends StatelessComponent {
  const AccordionChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      [
        // ํ† ํฐ-์ •ํ™• getter combo โ€” ์ด๋ฆ„์ด ๊ณง ๊ฐ’ (getter ์ด๋ฆ„ = Core ํ† ํฐ ์ƒ์ˆ˜ 1:1).
        Accordion(
          items: [
            AccordionItem(
              title: 'Is it accessible?',
              content: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
              expanded: true,
            ),
            AccordionItem(
              title: 'Is it styled?',
              content: Text(
                'Yes. It comes with default styles that matches the other components.',
              ),
            ),
            AccordionItem(
              title: 'Is it animated?',
              content: Text(
                'Yes. It\'s animated by default, but you can disable it if you prefer.',
              ),
            ),
          ],
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control โ€” getter ๊ฐ€ ์—†๋Š” ํ•„๋“œ(borderColor/borderWidth)๊นŒ์ง€.
        Accordion(
          items: [
            AccordionItem(
              title: 'Is it accessible?',
              content: Text('Yes. It adheres to the WAI-ARIA design pattern.'),
              expanded: true,
            ),
            AccordionItem(
              title: 'Is it styled?',
              content: Text(
                'Yes. It comes with default styles that matches the other components.',
              ),
            ),
            AccordionItem(
              title: 'Is it animated?',
              content: Text(
                'Yes. It\'s animated by default, but you can disable it if you prefer.',
              ),
            ),
          ],
        ).withStyle(
          const CoreAccordionStyle(
            backgroundColor: CoreColor.token(
              CoreColors.surfaceContainerHighest,
            ),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderWidth: CoreStrokeWidth.stroke2,
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

๋ณ€ํ˜• (Variants)#

์•„์ด์ฝ˜ ํฌํ•จ (์ปค์Šคํ…€ ํŠธ๋ฆฌ๊ฑฐ)#

title ์€ ๋ฌธ์ž์—ด ์ „์šฉ์ด๋ผ, ํ—ค๋”์— ์•„์ด์ฝ˜์„ ๋„ฃ์œผ๋ ค๋ฉด trigger ์Šฌ๋กฏ์— ์ง์ ‘ ์กฐ๋ฆฝํ•ฉ๋‹ˆ๋‹ค. Flutter ๋Š” AccordionTrigger ๋กœ ๊ฐ์‹ธ๋ฉด ํ† ๊ธ€ยทํฌ์ปค์Šคยทchevron ์ด ๊ทธ๋Œ€๋กœ ๋”ฐ๋ผ์˜ค๊ณ , Web ์˜ trigger ๋Š” ์ž„์˜ Component ๋ผ ํ•„์š”ํ•œ ์ธํ„ฐ๋ž™์…˜์„ ์ง์ ‘ ๋ถ™์ž…๋‹ˆ๋‹ค.

// Flutter
AccordionItem(
  trigger: AccordionTrigger(
    child: Row(
      children: [
        Icon(LucideIcons.user),
        Gap(gapStyle: CoreGapStyle(size: CoreSpace.space8)),
        Text('๊ฐœ์ธ ์ •๋ณด').bodyMedium.onSurface,
      ],
    ),
  ),
  content: PersonalInfoForm(),
)

FAQ ์Šคํƒ€์ผ#

Accordion(
  items: faqList.map((faq) => AccordionItem(
    title: faq.question,
    content: Text(faq.answer),
  )).toList(),
)

๋‹จ์ผ ํ•ญ๋ชฉ (์ ‘๊ธฐ/ํŽผ์น˜๊ธฐ)#

AccordionItem(
  title: '๊ณ ๊ธ‰ ์„ค์ •',
  expanded: false,
  content: AdvancedSettingsPanel(),
)

๋™์ž‘ ์ŠคํŽ™ (Behavior)#

์—ด๊ธฐ/๋‹ซ๊ธฐ#

  • ํ—ค๋” ํด๋ฆญ ์‹œ ํ•ด๋‹น ์„น์…˜์ด ์Šฌ๋ผ์ด๋“œ ์• ๋‹ˆ๋ฉ”์ด์…˜์œผ๋กœ ํŽผ์ณ์ง/์ ‘ํž˜
  • allowMultiple: false (๊ธฐ๋ณธ): ํ•˜๋‚˜๋ฅผ ์—ด๋ฉด ์ด์ „์— ์—ด๋ฆฐ ํ•ญ๋ชฉ์ด ์ž๋™์œผ๋กœ ๋‹ซํž˜
  • allowMultiple: true: ์—ฌ๋Ÿฌ ํ•ญ๋ชฉ์„ ๋™์‹œ์— ์—ด ์ˆ˜ ์žˆ์Œ
  • expanded ๋Š” ํ•ญ๋ชฉ์„ ๋งŒ๋“ค ๋•Œ์˜ ์ดˆ๊ธฐ๊ฐ’์ž…๋‹ˆ๋‹ค. ์—ด๋ฆผ์€ ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์—์„œ ๊ด€๋ฆฌํ•˜๋ฉฐ ์™ธ๋ถ€ ์ œ์–ด ๊ฐ’ ยท ๋ณ€๊ฒฝ ์ฝœ๋ฐฑ ยท ๋น„ํ™œ์„ฑ ํ”Œ๋ž˜๊ทธ๋Š” ์—†์Šต๋‹ˆ๋‹ค. ์ดˆ๊ธฐ๊ฐ’์œผ๋กœ ์—ฌ๋Ÿฌ ํ•ญ๋ชฉ์„ ์—ด ๋•Œ๋Š” ๋‹จ์ผ ์—ด๋ฆผ ๋ชจ๋“œ๊ฐ€ ์ดˆ๊ธฐ ์ƒํƒœ๋ฅผ ์ •๊ทœํ™”ํ•œ๋‹ค๊ณ  ๊ธฐ๋Œ€ํ•˜์ง€ ๋งˆ์„ธ์š”.

์• ๋‹ˆ๋ฉ”์ด์…˜#

  • ๊ธฐ๋ณธ CoreDuration.normal (200ms), easeInOut ์ปค๋ธŒ
  • ์‹œ๊ฐ„์€ accordionStyle.duration ์œผ๋กœ ์กฐ์ •ํ•ฉ๋‹ˆ๋‹ค (ํ”„๋กœ์ ํŠธ ๊ณตํ†ต์€ CoreAccordionTheme.style.duration)
Accordion(
  items: accordionItems,
  accordionStyle: CoreAccordionStyle(
    duration: Duration(milliseconds: CoreDuration.moderate),
  ),
)

ํ™”์‚ดํ‘œ ์•„์ด์ฝ˜#

  • ํ—ค๋” ์šฐ์ธก์˜ chevron ์•„์ด์ฝ˜์ด ์—ด๋ฆผ/๋‹ซํž˜ ๋ฐฉํ–ฅ์„ ํ‘œ์‹œ (180ยฐ ํšŒ์ „ ํŠธ๋žœ์ง€์…˜)
  • ํฌ๊ธฐยท์ƒ‰์€ accordionStyle.chevronIconStyle (๊ธฐ๋ณธ๊ฐ’์€ arrowIconStyle) ๋กœ ์กฐ์ •ํ•ฉ๋‹ˆ๋‹ค
  • ์•„์ด์ฝ˜ ๊ธ€๋ฆฌํ”„ ์ž์ฒด๋ฅผ ๋ฐ”๊พธ๋ ค๋ฉด trigger ์Šฌ๋กฏ์— ํ—ค๋”๋ฅผ ์ง์ ‘ ์กฐ๋ฆฝํ•ฉ๋‹ˆ๋‹ค

์‚ฌ์šฉ ๊ฐ€์ด๋“œ๋ผ์ธ (Usage Guidelines)#

โœ… Do#

์ž์ฃผ ๋ฌป๋Š” ์งˆ๋ฌธ์— ์•„์ฝ”๋””์–ธ์„ ํ™œ์šฉํ•˜์„ธ์š”.

Accordion(
  items: faqList.map((faq) => AccordionItem(
    title: faq.question,
    content: Text(faq.answer),
  )).toList(),
)

์งˆ๋ฌธ์„ ํ›‘์–ด๋ณด๊ณ  ์›ํ•˜๋Š” ๋‹ต๋ณ€๋งŒ ํŽผ์ณ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


โŒ Don't#

ํ•„์ˆ˜๋กœ ๋ด์•ผ ํ•˜๋Š” ๋‚ด์šฉ์„ ์•„์ฝ”๋””์–ธ์— ์ˆจ๊ธฐ์ง€ ๋งˆ์„ธ์š”.

Accordion(items: [
  AccordionItem(
    title: '๊ฒฐ์ œ ์ •๋ณด',
    content: PaymentForm(), // ๋ฐ˜๋“œ์‹œ ์ž…๋ ฅํ•ด์•ผ ํ•˜๋Š” ํผ
  ),
])

์ค‘์š”ํ•œ ๋‚ด์šฉ์ด ์ ‘ํ˜€ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ์ž๊ฐ€ ๋†“์น  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

โœ… Do#

์„น์…˜ ์ˆ˜๊ฐ€ ๋งŽ์œผ๋ฉด ์•„์ฝ”๋””์–ธ์„ ์‚ฌ์šฉํ•˜์„ธ์š”.

Accordion(
  items: categories.map((cat) => AccordionItem(
    title: cat.name,
    content: cat.settingsPanel,
  )).toList(),
)

์„ค์ • ํŽ˜์ด์ง€์ฒ˜๋Ÿผ ๋งŽ์€ ์„น์…˜์„ ํ•œ ํŽ˜์ด์ง€์— ๋‹ด์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


โŒ Don't#

์ ‘์„ ํ•„์š”๊ฐ€ ์—†๋Š” ๋‚ด์šฉ์— ์•„์ฝ”๋””์–ธ์„ ์‚ฌ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”.

Accordion(items: [
  AccordionItem(title: '์œ ์ผํ•œ ์„น์…˜', content: onlySection),
])

ํ•ญ๋ชฉ ์ˆ˜ ์ œํ•œ์€ ์—†์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ํ•ญ์ƒ ์ฝ์–ด์•ผ ํ•˜๊ณ  ์ ‘์–ด์„œ ์–ป๋Š” ๊ณต๊ฐ„๋„ ์—†๋‹ค๋ฉด ๊ทธ๋Œ€๋กœ ํ‘œ์‹œํ•˜์„ธ์š”.

์ ‘๊ทผ์„ฑ (Accessibility)#

์—ญํ• #

  • Flutter โ€” ํ—ค๋”๋Š” Clickable ํ•ฉ์„ฑ์ด๋ผ Semantics(button: true, enabled: โ€ฆ) ๋ฅผ ๋ฐ›๊ณ (clickable.dart:505), ๊ทธ ์œ„๋ฅผ CoUISemantics(expanded: โ€ฆ) ๊ฐ€ ๊ฐ์‹ธ ํŽผ์นจ ์ƒํƒœ๋ฅผ ์–น์Šต๋‹ˆ๋‹ค(accordion.dart:488). Accordion ๋ฃจํŠธ์™€ ํ•ญ๋ชฉ ๋ž˜ํผ์—๋Š” role ์ด ์—†์Šต๋‹ˆ๋‹ค.
  • Web โ€” ํ—ค๋”๋Š” Clickable ์˜ <div> ๋ผ role="button" + tabindex="0" ์ด ๋ถ™๊ณ (clickable.dart:436), ๊ทธ ์œ„์— aria-expanded ๊ฐ€ caller attributes ๋กœ ์‹ค๋ฆฝ๋‹ˆ๋‹ค(accordion.dart:363). ๋ฃจํŠธ <div> ๋Š” configureAttributes ๋ฅผ override ํ•˜์ง€ ์•Š์•„ ํ˜ธ์ถœ์ž๊ฐ€ ๋„˜๊ธด attributes ์™ธ์—๋Š” ์•„๋ฌด role ๋„ ๋ถ™์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์–‘ ํ”Œ๋žซํผ ๋ชจ๋‘ ํ—ค๋”์™€ ํŒจ๋„์„ ์ž‡๋Š” aria-controls ๋„, ํŒจ๋„์˜ region role ๋„, ํ—ค๋”ฉ ๋ ˆ๋ฒจ๋„ ๋‚ด๋ณด๋‚ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค โ€” ๋ฆฌ๋”์˜ heading ๋ชฉ๋ก์— ์•„์ฝ”๋””์–ธ ํ—ค๋”๋Š” ์žกํžˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

ํ‚ค๋ณด๋“œ#

ํ‚คFlutterWeb
Enter / Spaceํฌ์ปค์Šค๋œ ํ—ค๋” ์—ด๊ธฐ/๋‹ซ๊ธฐํฌ์ปค์Šค๋œ ํ—ค๋” ์—ด๊ธฐ/๋‹ซ๊ธฐ
Tab / Shift+Tabํ—ค๋”๋งˆ๋‹ค ๊ฐœ๋ณ„ tab stopํ—ค๋”๋งˆ๋‹ค ๊ฐœ๋ณ„ tab stop
ArrowUp / ArrowDown ๋กœ ํ—ค๋” ๊ฐ„ ์ด๋™ ์—†์Œ ์—†์Œ
Home / End ๋กœ ์ฒซ/๋งˆ์ง€๋ง‰ ํ—ค๋” ์—†์Œ ์—†์Œ

์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋ฐ”์ธ๋”ฉํ•˜๋Š” ํ‚ค๋Š” Enter / Space ๋ฟ์ž…๋‹ˆ๋‹ค(clickable.dart:14). WAI-ARIA ์•„์ฝ”๋””์–ธ ํŒจํ„ด์ด ์„ ํƒ ์‚ฌํ•ญ์œผ๋กœ ๋‘๋Š” ํ—ค๋” ๊ฐ„ ํ™”์‚ดํ‘œยทHome/End ์ด๋™์€ ๊ตฌํ˜„๋ผ ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

Flutter ์—์„œ ํ™”์‚ดํ‘œ๋กœ ํฌ์ปค์Šค๊ฐ€ ์›€์ง์ด๋Š” ๊ฒƒ์€ ํ”„๋ ˆ์ž„์›Œํฌ์˜ ๋ฐฉํ–ฅ์„ฑ ์ˆœํšŒ์ด์ง€ ์•„์ฝ”๋””์–ธ์˜ ๋™์ž‘์ด ์•„๋‹™๋‹ˆ๋‹ค โ€” ์•„์ฝ”๋””์–ธ์— ์Šค์ฝ”ํ”„๋˜์ง€ ์•Š๊ณ , Flutter Web ์—์„œ๋Š” ๊ฐ™์€ ํ‚ค๊ฐ€ ์Šคํฌ๋กค๋กœ ๊ฐ‘๋‹ˆ๋‹ค.

์Šคํฌ๋ฆฐ ๋ฆฌ๋”#

์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ ์ด๋ฆ„์€ title ๋ฌธ์ž์—ด์ด ๊ทธ๋Œ€๋กœ ํ—ค๋” ํ…์ŠคํŠธ๊ฐ€ ๋˜์–ด ๋‚˜์˜ต๋‹ˆ๋‹ค(Flutter accordion.dart:324, Web accordion.dart:402). trigger ์Šฌ๋กฏ์„ ์ฑ„์šฐ๋ฉด ์ด๋ฆ„์€ ๊ทธ ์•ˆ์— ๋„ฃ์€ ์ฝ˜ํ…์ธ ์—์„œ ์˜ต๋‹ˆ๋‹ค.

ํŽผ์นจ ์ƒํƒœ๋Š” ํ—ค๋”์— ์‹ค๋ ค ํ•จ๊ป˜ ์ฝํžˆ์ง€๋งŒ, ์—ฌ๋‹ซ์€ ๊ฒฐ๊ณผ๋ฅผ ์•Œ๋ฆฌ๋Š” live region ์€ ์–‘์ชฝ ๋‹ค ์—†์Šต๋‹ˆ๋‹ค โ€” ์‚ฌ์šฉ์ž๋Š” ํ—ค๋”๋กœ ๋˜๋Œ์•„์™€ ์ƒํƒœ๋ฅผ ๋‹ค์‹œ ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ ‘ํžŒ ํŒจ๋„์€ ๋ฆฌ๋”์— ๋…ธ์ถœ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. Flutter ๋Š” ๋†’์ด 0 ์œผ๋กœ ํด๋ฆฝ๋˜์–ด ์‹œ๋งจํ‹ฑ ํŠธ๋ฆฌ์— ์˜ฌ๋ผ๊ฐ€์ง€ ์•Š๊ณ , Web ์€ inert ๋กœ ์ ‘๊ทผ์„ฑ ํŠธ๋ฆฌยทํƒญ ์ˆœ์„œยทhit-test ์—์„œ ํ•จ๊ป˜ ๋น ์ง‘๋‹ˆ๋‹ค(accordion.dart:439).

ํ•ญ๋ชฉ ์‚ฌ์ด ๊ตฌ๋ถ„์„ ์€ Divider ๊ธฐ๋ณธ๊ฐ’(semantic: false)์ด๋ผ ๋ฆฌ๋”์— ์•„๋ฌด๊ฒƒ๋„ ์ฝํžˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค(Flutter divider.dart:51, Web divider.dart:76).

ํฌ์ปค์Šค ๊ด€๋ฆฌ#

  • ์ง„์ž… / ์ดํƒˆ โ€” ์—†์Œ. ์˜ค๋ฒ„๋ ˆ์ด๊ฐ€ ์•„๋‹ˆ๋ผ ์ธ๋ผ์ธ ์„น์…˜์ด๋ผ ์—ด๊ณ  ๋‹ซ์„ ๋•Œ ํฌ์ปค์Šค๋ฅผ ์˜ฎ๊ธฐ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ํŠธ๋žฉ โ€” ์—†์Œ. Tab ์€ ์•„์ฝ”๋””์–ธ ๋ฐ–์œผ๋กœ ๊ทธ๋Œ€๋กœ ๋น ์ ธ๋‚˜๊ฐ‘๋‹ˆ๋‹ค.
  • ํŽผ์นœ ํŒจ๋„ ์•ˆ์˜ ํฌ์ปค์Šค ๊ฐ€๋Šฅ ์š”์†Œ๋Š” ํƒญ ์ˆœ์„œ์— ๋“ค์–ด์˜ค๊ณ , ์ ‘์œผ๋ฉด ๋น ์ง‘๋‹ˆ๋‹ค. Flutter ๋Š” ๋‹ซํž˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ๋๋‚œ ์‹œ์ (_controller.isDismissed)์— ExcludeFocus ๋กœ ์ œ์™ธํ•˜๊ณ (accordion.dart:351), Web ์€ ์ƒํƒœ๊ฐ€ ๋’ค์ง‘ํžˆ๋Š” ์ฆ‰์‹œ inert ๋ฅผ ๋ถ™์ž…๋‹ˆ๋‹ค(accordion.dart:439).
  • ํŒจ๋„ ์•ˆ์— ํฌ์ปค์Šค๊ฐ€ ์žˆ๋Š” ์ฑ„๋กœ ์ ‘์œผ๋ฉด ํฌ์ปค์Šค๋ฅผ ํ—ค๋”๋กœ ๋˜๋Œ๋ฆฌ๋Š” ์ฝ”๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

์•Œ๋ ค์ง„ ์ œ์•ฝ#

  • Web ์—์„œ trigger ์Šฌ๋กฏ์„ ์ฑ„์šฐ๋ฉด ๊ทธ ํ—ค๋”๋Š” ๋™์ž‘ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค โ€” ์ปค์Šคํ…€ ํŠธ๋ฆฌ๊ฑฐ๋Š” ๊ทธ๋Œ€๋กœ ๋ Œ๋”๋  ๋ฟ ํ† ๊ธ€ ๋ฐฐ์„ ๋„ aria-expanded ๋„ ๋ถ™์ง€ ์•Š๊ณ (accordion.dart:402), ํŽผ์นจ ์ƒํƒœ๋Š” ๋ถ€๋ชจ Accordion ์ด ์ธ๋ฑ์Šค๋กœ ๋“ค๊ณ  ์žˆ์–ด ํ˜ธ์ถœ์ž๊ฐ€ ๋ฐ–์—์„œ ๋ฐ”๊ฟ€ ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค. Web ์—์„œ๋Š” title ์„ ์“ฐ์„ธ์š”. Flutter ๋Š” AccordionTrigger ๋กœ ๊ฐ์‹ธ๋ฉด ํ† ๊ธ€ยทํฌ์ปค์ŠคยทํŽผ์นจ ์ƒํƒœ๊ฐ€ ๊ทธ๋Œ€๋กœ ๋”ฐ๋ผ์˜ต๋‹ˆ๋‹ค(accordion.dart:324).
  • ํ—ค๋”์™€ ํŒจ๋„์— ์†์„ฑ์„ ์‹ค์„ ์ง„์ž…์ ์ด ์—†์Šต๋‹ˆ๋‹ค โ€” Web AccordionItem(attributes: โ€ฆ) ๋Š” ํ•ญ๋ชฉ ๋ž˜ํผ <div> ์—๋งŒ ๋‹ฟ๊ณ (accordion.dart:445) ํ—ค๋”(Clickable)์™€ ํŒจ๋„์—๋Š” caller ํ‘œ๋ฉด์ด ์—†์œผ๋ฉฐ, Flutter AccordionItem ์—๋Š” ๊ทธ๋Ÿฐ ํ‘œ๋ฉด ์ž์ฒด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์œ„์—์„œ ๋งํ•œ aria-controls / ํ—ค๋”ฉ ๋ ˆ๋ฒจ์„ ์†Œ๋น„์ž๊ฐ€ ๋ฐ–์—์„œ ๋ณด๊ฐ•ํ•  ์ˆ˜ ์—†๋‹ค๋Š” ๋œป์ž…๋‹ˆ๋‹ค.

์ „์—ญ ์ถ•(๋™์ž‘ ์ค„์ด๊ธฐ ยท ๊ณ ๋Œ€๋น„ ยท ์ƒ‰ ๊ฐ•์ œ ๋ชจ๋“œ ยท ์‹œ๋งจํ‹ฑ ์—ญํ•  ยท ์ตœ์†Œ ํ„ฐ์น˜ ํƒ€๊ฒŸ)์€ ์ ‘๊ทผ์„ฑ ํŽ˜์ด์ง€๊ฐ€ ํ•œ ๊ณณ์—์„œ ๋‹ค๋ฃน๋‹ˆ๋‹ค.

ํฌ๋กœ์Šค ํ”Œ๋žซํผ ์ฐจ์ด์  (Platform Differences)#

ํ•ญ๋ชฉFlutterWeb
์Šฌ๋กฏ ํƒ€์ž… items: List<Widget> / content: Widget items: List<Component> / content: Component
์ปค์Šคํ…€ ํ—ค๋” AccordionTrigger ๋กœ ๊ฐ์‹ธ๋ฉด ๊ธฐ๋ณธ ํ† ๊ธ€ ๋ฐฐ์„  ์ƒ์† trigger ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ Œ๋”ํ•ด ์ž๋™ ํ† ๊ธ€ ๋ฐฐ์„  ์—†์Œ
ํŽผ์นจ ์• ๋‹ˆ๋ฉ”์ด์…˜ SizeTransition + AnimationController CSS grid-rows ํŠธ๋žœ์ง€์…˜
์ ‘ํžŒ ํŒจ๋„ ๊ฒฉ๋ฆฌExcludeFocusinert ์†์„ฑ
  • Tabs: ์ฝ˜ํ…์ธ ๋ฅผ ํƒญ์œผ๋กœ ์ „ํ™˜. ์•„์ฝ”๋””์–ธ๊ณผ ๋‹ฌ๋ฆฌ ํ•œ ๋ฒˆ์— ํ•˜๋‚˜์˜ ํŒจ๋„๋งŒ ํ‘œ์‹œ
  • Card: ์ฝ˜ํ…์ธ  ์ปจํ…Œ์ด๋„ˆ. ์•„์ฝ”๋””์–ธ ํ•ญ๋ชฉ ๋‚ด๋ถ€์— ์นด๋“œ๋ฅผ ๋ฐฐ์น˜ ๊ฐ€๋Šฅ

์กฐํ•ฉ ์˜ˆ์ œ#

// ์„ค์ • ํŽ˜์ด์ง€ ํŒจํ„ด
Accordion(
  allowMultiple: true,
  items: [
    AccordionItem(
      title: 'ํ”„๋กœํ•„ ์„ค์ •',
      content: ProfileSettingsForm(),
    ),
    AccordionItem(
      title: '์•Œ๋ฆผ ์„ค์ •',
      content: NotificationSettingsForm(),
    ),
    AccordionItem(
      title: '๋ณด์•ˆ ์„ค์ •',
      content: SecuritySettingsForm(),
    ),
  ],
)